From e0bc9a96522b64eff44ecc71db231a61c4e93ef2 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:29:40 +0200 Subject: [PATCH 01/43] chore: remove redundant parseIssues usage in examples --- examples/angular/field-groups/src/bound-field.component.ts | 5 +++-- examples/lit/field-groups/src/field-bounds.ts | 4 ++-- examples/preact/field-groups/src/fieldGroups/fieldBounds.tsx | 4 ++-- examples/react/field-groups/src/fieldGroups/fieldBounds.tsx | 4 ++-- examples/solid/field-groups/src/fieldGroups/fieldBounds.tsx | 4 ++-- .../field-groups/src/fieldGroups/UpperBoundFields.svelte | 4 ++-- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/examples/angular/field-groups/src/bound-field.component.ts b/examples/angular/field-groups/src/bound-field.component.ts index 609443897d..6f991e8f3d 100644 --- a/examples/angular/field-groups/src/bound-field.component.ts +++ b/examples/angular/field-groups/src/bound-field.component.ts @@ -50,9 +50,9 @@ export class BoundFieldComponent { watchFields: this.fields().lowerBound ? [this.fields().lowerBound!] : undefined, - run: ({ value, parseIssues }: any) => { + run: ({ value }: any) => { const valueResult = boundsSchema.safeParse(value) - if (!valueResult.success) return parseIssues(valueResult.error.issues) + if (!valueResult.success) return valueResult.error.issues const lowerName = this.fields().lowerBound if (!lowerName) return const lowerResult = boundsSchema.safeParse( @@ -61,6 +61,7 @@ export class BoundFieldComponent { if (lowerResult.success && valueResult.data < lowerResult.data) { return 'Upper bound must be greater than lower bound' } + return undefined }, }, ]) diff --git a/examples/lit/field-groups/src/field-bounds.ts b/examples/lit/field-groups/src/field-bounds.ts index 9bc4aec3ab..ad70124a00 100644 --- a/examples/lit/field-groups/src/field-bounds.ts +++ b/examples/lit/field-groups/src/field-bounds.ts @@ -55,9 +55,9 @@ function upperBoundField(props: { { triggers: ['change'], watchFields: ['lowerBound'], - run: ({ value, parseIssues }) => { + run: ({ value }) => { const upper = boundsSchema.safeParse(value) - if (!upper.success) return parseIssues(upper.error.issues) + if (!upper.success) return upper.error.issues const lower = boundsSchema.safeParse( props.fields.getFieldValue('lowerBound'), ) diff --git a/examples/preact/field-groups/src/fieldGroups/fieldBounds.tsx b/examples/preact/field-groups/src/fieldGroups/fieldBounds.tsx index 196015c3b4..0eb10f278f 100644 --- a/examples/preact/field-groups/src/fieldGroups/fieldBounds.tsx +++ b/examples/preact/field-groups/src/fieldGroups/fieldBounds.tsx @@ -78,11 +78,11 @@ function UpperBoundFieldComponent(props: UpperBoundFieldProps) { // `watchFields` also uses virtual names. The wrapper rewrites // `lowerBound` to the caller's concrete path, such as `minPrice`. watchFields: ['lowerBound'], - run: ({ value, parseIssues }) => { + run: ({ value }) => { const upperBoundResult = boundsSchema.safeParse(value) if (!upperBoundResult.success) { - return parseIssues(upperBoundResult.error.issues) + return upperBoundResult.error.issues } const lowerBoundResult = boundsSchema.safeParse( diff --git a/examples/react/field-groups/src/fieldGroups/fieldBounds.tsx b/examples/react/field-groups/src/fieldGroups/fieldBounds.tsx index c7d9575b45..d1eb30abd3 100644 --- a/examples/react/field-groups/src/fieldGroups/fieldBounds.tsx +++ b/examples/react/field-groups/src/fieldGroups/fieldBounds.tsx @@ -78,11 +78,11 @@ function UpperBoundFieldComponent(props: UpperBoundFieldProps) { // `watchFields` also uses virtual names. The wrapper rewrites // `lowerBound` to the caller's concrete path, such as `minPrice`. watchFields: ['lowerBound'], - run: ({ value, parseIssues }) => { + run: ({ value }) => { const upperBoundResult = boundsSchema.safeParse(value) if (!upperBoundResult.success) { - return parseIssues(upperBoundResult.error.issues) + return upperBoundResult.error.issues } const lowerBoundResult = boundsSchema.safeParse( diff --git a/examples/solid/field-groups/src/fieldGroups/fieldBounds.tsx b/examples/solid/field-groups/src/fieldGroups/fieldBounds.tsx index cc204509fa..9c6127e896 100644 --- a/examples/solid/field-groups/src/fieldGroups/fieldBounds.tsx +++ b/examples/solid/field-groups/src/fieldGroups/fieldBounds.tsx @@ -60,11 +60,11 @@ function UpperBoundFieldComponent(props: UpperBoundFieldProps) { { triggers: ['change'], watchFields: ['lowerBound'], - run: ({ value, parseIssues }) => { + run: ({ value }) => { const upperBoundResult = boundsSchema.safeParse(value) if (!upperBoundResult.success) { - return parseIssues(upperBoundResult.error.issues) + return upperBoundResult.error.issues } const lowerBoundResult = boundsSchema.safeParse( diff --git a/examples/svelte/field-groups/src/fieldGroups/UpperBoundFields.svelte b/examples/svelte/field-groups/src/fieldGroups/UpperBoundFields.svelte index 32922db6e4..22565e2a17 100644 --- a/examples/svelte/field-groups/src/fieldGroups/UpperBoundFields.svelte +++ b/examples/svelte/field-groups/src/fieldGroups/UpperBoundFields.svelte @@ -17,10 +17,10 @@ { triggers: ['change'], watchFields: ['lowerBound'], - run: ({ value, parseIssues }) => { + run: ({ value }) => { const upperBoundResult = boundsSchema.safeParse(value) if (!upperBoundResult.success) { - return parseIssues(upperBoundResult.error.issues) + return upperBoundResult.error.issues } const lowerBoundResult = boundsSchema.safeParse( fields.getFieldValue('lowerBound'), From 6c2d57702250fb60ee13421f4695f5438a4453d5 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:48:54 +0200 Subject: [PATCH 02/43] BREAKING: deprecate parseIssues in favor of createErrorMap --- .../form-core/src/FormApi/FormApi.public.ts | 57 ++++++++----- .../form-core/src/FormApi/handleSubmit.lib.ts | 23 ++++-- .../src/FormGroupApi/FormGroupApi.lib.ts | 7 +- packages/form-core/src/createErrorMap.lib.ts | 23 ++++++ packages/form-core/src/ssr.lib.ts | 7 +- packages/form-core/src/types.public.ts | 1 + packages/form-core/src/utils.lib.ts | 19 ++++- packages/form-core/src/validation.public.ts | 79 ++++++------------- .../form-core/src/validation/execution.lib.ts | 17 +--- .../form-core/src/validation/mount.lib.ts | 18 +---- .../form-core/src/validation/pipeline.lib.ts | 11 +-- .../tests/FormApi/submission-handling.spec.ts | 4 +- .../tests/FormApi/validation.spec.ts | 6 +- .../tests/FormGroupApi/FormGroupApi.spec.ts | 4 +- .../tests/validation-pipeline.test.ts | 6 +- .../form-core/tests/validation-public.test.ts | 38 --------- packages/form-core/tests/validation.test-d.ts | 51 +++--------- 17 files changed, 145 insertions(+), 226 deletions(-) create mode 100644 packages/form-core/src/createErrorMap.lib.ts diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index f7824c0907..fbc63e1b2f 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -9,6 +9,7 @@ import type { ParsedStandardSchemaIssues, ToFormErrorTypes, ToFormOutputs, + ValidationErrorMap, } from '../validation.public' import type { StandardSchemaV1Issue } from '../standardSchema.public' import type { FormListeners } from '../listeners.public' @@ -18,9 +19,9 @@ import type { ServerFormState } from '../ssr.public' import type { SUBMIT_ERROR } from './handleSubmit.lib' /** - * A validation error wrapped for processing as an `onSubmit` failure. + * A validation error recognized as an `onSubmit` failure. * - * Create this value with the `createValidationError` or `parseIssues` helper + * Create this value with the `createValidationError` or `createErrorMap` helper * provided to `onSubmit`, then return it from the callback so its form- and * field-level errors are added to validation state. * @@ -63,17 +64,29 @@ export type CreateValidationErrorFn = < ) => OnSubmitError /** - * Converts Standard Schema issues into an `onSubmit` validation error. + * Creates an error map that can be returned directly from `onSubmit`. * - * Issue paths are mapped to fields in the submitted value. Return the result - * from `onSubmit` to add the parsed errors to validation state. + * Build a mutable map or pass Standard Schema issues to route them using the + * submitted values. Narrow possibly undefined issue lists before calling this + * helper. Empty maps indicate success. * - * @param issues - The Standard Schema issues to convert. * @typeParam TFormData - Library-managed. Do not specify explicitly. */ -export type ParseSubmitIssuesFn = ( - issues: ReadonlyArray, -) => OnSubmitError> +export interface CreateSubmitErrorMapFn { + /** Creates a mutable submission error map with an empty `fields` object. */ + (): ValidationErrorMap & + OnSubmitError> + /** Reuses the supplied map, adding an empty `fields` object if it is missing. */ + ( + initial: Partial>, + ): ValidationErrorMap & + OnSubmitError> + /** Routes issues while preserving Standard Schema issue arrays in the result. */ + ( + issues: ReadonlyArray, + ): ParsedStandardSchemaIssues & + OnSubmitError> +} /** * Context passed to `onSubmit` after submission validation succeeds. @@ -147,7 +160,7 @@ export interface FormSubmitContext< */ createValidationError: CreateValidationErrorFn /** - * Parses Standard Schema issues into an error returnable from `onSubmit`. + * Builds an error map or routes Standard Schema issues for `onSubmit`. * * Issue paths are mapped to fields in the submitted value. Return the parsed * error from `onSubmit` to add those errors to validation state. @@ -156,24 +169,24 @@ export interface FormSubmitContext< * ```ts * { * // ... - * onSubmit: async ({ value, parseIssues }) => { + * onSubmit: async ({ value, createErrorMap }) => { * const result = zodSchema.safeParse(value) * * if (!result.success) { - * return parseIssues(result.error.issues) + * return createErrorMap(result.error.issues) * } * }, * } * ``` */ - parseIssues: ParseSubmitIssuesFn + createErrorMap: CreateSubmitErrorMapFn } /** * Handles a form submission after submission validation succeeds. * * A returned promise is awaited before submission finishes. Return an error - * created by the submission context's `createValidationError` or `parseIssues` + * created by the submission context's `createValidationError` or `createErrorMap` * helper to mark the submission as invalid. * * @typeParam TFormData - Library-managed. Do not specify explicitly. @@ -365,7 +378,7 @@ export interface FormOptions< /** * Called after submission validation succeeds. * - * Return an error created with `createValidationError` or `parseIssues` to + * Return an error created with `createValidationError` or `createErrorMap` to * mark the submission as invalid. A returned promise is awaited before * submission finishes. If the callback throws, `onSubmitInvalid` is called. * @@ -584,15 +597,16 @@ export interface FormResetOptions { * Registered fields are marked touched, field validators run before form * validators, and `onSubmit` is awaited only when validation succeeds. * Validation error results and errors returned by `onSubmit` through - * `createValidationError` are stored as error state. `onSubmitInvalid` is - * awaited after a failed attempt. + * `createValidationError` or `createErrorMap` are stored as error state. + * `onSubmitInvalid` is awaited after a failed attempt. * * Calls made while an attempt is in progress return the same promise instead * of starting another attempt. * * @returns A promise resolving to the error results produced by field and * form validation, plus any validation error returned by `onSubmit` through - * `createValidationError`. The array is empty if none are produced. + * `createValidationError` or `createErrorMap`. The array is empty if none are + * produced. * @typeParam TFormData - Library-managed. Do not specify explicitly. */ export type HandleSubmitFn = () => Promise< @@ -678,15 +692,16 @@ export interface FormApi< * Registered fields are marked touched, field validators run before form * validators, and `onSubmit` is awaited only when validation succeeds. * Validation error results and errors returned by `onSubmit` through - * `createValidationError` are stored as error state. `onSubmitInvalid` is - * awaited after a failed attempt. + * `createValidationError` or `createErrorMap` are stored as error state. + * `onSubmitInvalid` is awaited after a failed attempt. * * Calls made while an attempt is in progress return the same promise instead * of starting another attempt. * * The returned promise resolves to the error results produced by field and * form validation, plus any validation error returned by `onSubmit` through - * `createValidationError`. The array is empty if none are produced. + * `createValidationError` or `createErrorMap`. The array is empty if none are + * produced. */ handleSubmit: HandleSubmitFn /** diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index 634755d402..3fed6d4657 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -4,17 +4,19 @@ import { isValidationErrorMap, parseValidationResult, } from '../validation' -import { parseStandardSchemaIssues } from '../standardSchema.lib' +import { createErrorMapFactory } from '../createErrorMap.lib' +import type { StandardSchemaV1Issue } from '../standardSchema.public' import type { InternalFormApi } from './FormApi.lib' import type { FormValidateResult, FormValidationError, ValidationErrorInput, + ValidationErrorMap, } from '../validation.public' import type { + CreateSubmitErrorMapFn, CreateValidationErrorFn, OnSubmitError, - ParseSubmitIssuesFn, } from './FormApi.public' export const SUBMIT_ERROR = Symbol('SUBMIT_ERROR') @@ -39,12 +41,17 @@ function createSubmitError>( return { [SUBMIT_ERROR]: error } } -function createParseIssues( +function createSubmitErrorMapFactory( value: TFormData, -): ParseSubmitIssuesFn { - return (issues) => { - return createSubmitError(parseStandardSchemaIssues(issues, value, 'form')) - } +): CreateSubmitErrorMapFn { + const createErrorMap = createErrorMapFactory(value) + + return ((initial) => { + const map = createErrorMap(initial as never) + // Read the map itself so edits made after creation are included. + Object.defineProperty(map, SUBMIT_ERROR, { value: map }) + return map + }) as CreateSubmitErrorMapFn } export async function runSubmissionProcess( @@ -168,7 +175,7 @@ export async function runSubmissionProcess( validatorOutputs, value, createValidationError, - parseIssues: createParseIssues(value), + createErrorMap: createSubmitErrorMapFactory(value), }) if (hasResettedFormDuringSubmit()) { diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index b8f563b005..9d3d7b5d64 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -18,8 +18,7 @@ import { deriveFromBaseFieldMeta, hasFieldMetaErrors, } from '../FieldApi/fieldState.lib' -import { parseStandardSchemaIssues } from '../standardSchema.lib' -import { createErrorMap } from '../validation.public' +import { createErrorMapFactory } from '../createErrorMap.lib' import { reconcileValidatorInstances } from '../ValidatorInstance.lib' import { resolveDefaultOptions } from '../defaultOptions.lib' import type { FormApi } from '../FormApi/FormApi.public' @@ -685,10 +684,8 @@ export class InternalFormGroupApi< triggerFieldApi: opts?.triggerFieldApi, signal: ctx.signal, value: this.value, - createErrorMap, + createErrorMap: createErrorMapFactory(this.value), createOutput, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, this.value, 'form'), }), onResult: (result) => { this._processValidationResult(result, signal) diff --git a/packages/form-core/src/createErrorMap.lib.ts b/packages/form-core/src/createErrorMap.lib.ts new file mode 100644 index 0000000000..1326592cce --- /dev/null +++ b/packages/form-core/src/createErrorMap.lib.ts @@ -0,0 +1,23 @@ +import { parseStandardSchemaIssues } from './standardSchema.lib' +import { isReadonlyArray } from './utils.lib' +import type { CreateErrorMapFn } from './validation.public' + +type TupleNarrower = (arg: any) => arg is ReadonlyArray + +/** + * Standard schema needs path parsing, so the error map function needs context of form values. + */ +export function createErrorMapFactory( + value: TFormData, +): CreateErrorMapFn { + const createErrorMap = ((initial) => { + if (isReadonlyArray(initial)) { + return parseStandardSchemaIssues(initial, value, 'form') + } + const map = initial ?? {} + map.fields ??= {} + return map + }) as CreateErrorMapFn + + return createErrorMap +} diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index ea105f8b19..456fcddc34 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -2,10 +2,9 @@ import { batch } from '@tanstack/store' import { createOutput } from './validationOutput.lib' import { defaultInternalBaseFieldMeta } from './FieldApi/fieldState.lib' import { visitAllFormFields } from './FieldApi/fieldTraversal.lib' -import { parseStandardSchemaIssues } from './standardSchema.lib' import { evaluate } from './utils.lib' import { runValidatorPipeline } from './validation' -import { createErrorMap } from './validation.public' +import { createErrorMapFactory } from './createErrorMap.lib' import { devtools } from './devtoolsBridge.lib' import { reconcileValidatorInstances } from './ValidatorInstance.lib' import type { FormOptions } from './FormApi/FormApi.public' @@ -155,10 +154,8 @@ export async function validateServerValues< signal: ctx.signal, formApi: undefined, value: values, - createErrorMap, + createErrorMap: createErrorMapFactory(values), createOutput, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, values, 'form'), }), scope: 'form', }) diff --git a/packages/form-core/src/types.public.ts b/packages/form-core/src/types.public.ts index 0a3f0e913a..066f6c910b 100644 --- a/packages/form-core/src/types.public.ts +++ b/packages/form-core/src/types.public.ts @@ -31,6 +31,7 @@ export interface FieldUpdateOptions { } export type OneOrMany = TValue | Array +export type ReadonlyOneOrMany = TValue | ReadonlyArray /** * A field API that preserves its value type while erasing its name, error, and diff --git a/packages/form-core/src/utils.lib.ts b/packages/form-core/src/utils.lib.ts index d1e0005cb0..60f7efa72b 100644 --- a/packages/form-core/src/utils.lib.ts +++ b/packages/form-core/src/utils.lib.ts @@ -1,7 +1,11 @@ import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' // type -import type { FieldUpdateOptions, OneOrMany, Updater } from './types.public' +import type { + FieldUpdateOptions, + ReadonlyOneOrMany, + Updater, +} from './types.public' import type { InternalFieldUpdateOptions, ResolvedInternalFieldUpdateOptions, @@ -199,11 +203,11 @@ export function callUpdater(updater: Updater, object: T): T { } export function normalizeToArray( - input: null | undefined | OneOrMany, -): Array { + input: null | undefined | ReadonlyOneOrMany, +): ReadonlyArray { if (Array.isArray(input)) return input if (input === null || input === undefined) return [] - return [input] + return [input as T] } // TODO remove and import from store: @@ -322,3 +326,10 @@ export function isPromiseLike( typeof value.then === 'function' ) } + +/** + * Just `Array.isArray`, but better typed. We *could* make this with declaration merging, + * but I'd prefer to avoid introducing edge cases. + */ +export const isReadonlyArray: (arg: any) => arg is ReadonlyArray = + Array.isArray diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/validation.public.ts index d4651aa696..02e3ce3ae3 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/validation.public.ts @@ -6,7 +6,7 @@ import type { StandardSchemaV1, StandardSchemaV1Issue, } from './standardSchema.public' -import type { OneOrMany } from './types.public' +import type { OneOrMany, ReadonlyOneOrMany } from './types.public' import type { VALIDATION_OUTPUT } from './validationOutput.lib' /** @@ -585,7 +585,7 @@ export type ValidationErrorValue = ValidationIssue | string /** One or more issue objects after string errors have been normalized. */ export type ValidationError = OneOrMany /** One or more errors accepted from a validator, including string messages. */ -export type ValidationErrorInput = OneOrMany +export type ValidationErrorInput = ReadonlyOneOrMany /** * Errors owned by a form or group and errors routed to its fields. @@ -600,34 +600,23 @@ export interface ValidationErrorMap { } /** - * Creates a mutable map of form errors and errors for individual field paths. + * Creates an error map with field paths inferred from the callback's value shape. * - * Pass the completed map back from a form or group validator to report the errors. - * - * @param initial - An existing map to reuse. A missing `fields` object is added to it. - * @returns The initial map, if supplied, or a new map with an empty `fields` object. - * @typeParam TFormData - The form or group value shape whose field paths are - * accepted by the map. Specify it when calling this helper directly to restrict - * `fields` to that shape's paths. Without a known value shape, field paths are - * unrestricted. The validator context's `createErrorMap` already uses the form - * or group shape and needs no type argument. - */ -export function createErrorMap( - initial?: Partial>, -): ValidationErrorMap { - if (!initial) return { fields: {} } - - initial.fields ??= {} - return initial as ValidationErrorMap -} - -/** - * Creates a mutable error map with field paths inferred from the validator's value shape. + * Return the map from a form, group, or server validator to report its errors. + * Pass Standard Schema issues to retain all issues in `form` and also route + * issues with paths to `fields`. Narrow possibly undefined issue lists first. * * @typeParam TFormData - Library-managed. Do not specify explicitly. */ -export type CreateErrorMapFn = - typeof createErrorMap +export interface CreateErrorMapFn { + ( + initial?: Partial>, + ): ValidationErrorMap + /** Converts issues into a map containing only Standard Schema issue arrays. */ + ( + issues: ReadonlyArray, + ): ParsedStandardSchemaIssues +} /** * Standard Schema issues reported at form or group scope and routed to field paths. @@ -641,25 +630,6 @@ export interface ParsedStandardSchemaIssues { fields: Partial, Array>> } -/** - * Converts Standard Schema issues into errors owned by the field being validated. - * - * @returns A copy of the issue array. Issue paths do not route errors to other fields. - */ -export type ParseFieldIssuesFn = ( - issues: ReadonlyArray, -) => Array - -/** - * Converts Standard Schema issues into a form or group error map to return from a validator. - * - * @returns All issues in `form`, with issues that have paths also grouped in `fields`. - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export type ParseFormIssuesFn = ( - issues: ReadonlyArray, -) => ParsedStandardSchemaIssues - /** * A successful validation result that carries data for `validatorOutputs`. * @@ -719,9 +689,7 @@ export interface FormValidatorContext { triggerFieldApi?: AnyFieldApi /** The complete form values being validated. */ value: TFormData - /** Converts Standard Schema issues into form and field errors to return from this validator. */ - parseIssues: ParseFormIssuesFn - /** Creates an error map with field paths inferred from the form values. */ + /** Builds an error map or routes Standard Schema issues using the form values. */ createErrorMap: CreateErrorMapFn /** Creates a successful result to return data in this validator's `validatorOutputs` entry. */ createOutput: CreateOutputFn @@ -743,9 +711,7 @@ export interface ServerFormValidatorContext { triggerFieldApi?: AnyFieldApi /** The complete form values passed to server validation. */ value: TFormData - /** Converts Standard Schema issues into form and field errors to return from this validator. */ - parseIssues: ParseFormIssuesFn - /** Creates an error map with field paths inferred from the form values. */ + /** Builds an error map or routes Standard Schema issues using the server values. */ createErrorMap: CreateErrorMapFn /** Creates a successful result to return data in this validator's server `validatorOutputs` entry. */ createOutput: CreateOutputFn @@ -877,9 +843,7 @@ export interface FormGroupValidatorContext { triggerFieldApi?: AnyFieldApi /** The group's current value, without the surrounding form values. */ value: TGroupValue - /** Converts Standard Schema issues into group errors and errors at group-relative field paths. */ - parseIssues: ParseFormIssuesFn - /** Creates an error map whose field paths are relative to this group. */ + /** Builds an error map or routes Standard Schema issues at group-relative field paths. */ createErrorMap: CreateErrorMapFn /** Creates a successful result to return data in this validator's `validatorOutputs` entry. */ createOutput: CreateOutputFn @@ -954,8 +918,6 @@ export interface FieldValidatorContext< fieldApi: FieldApi /** The current value of the field being validated. */ value: TFieldValue - /** Converts Standard Schema issues into errors for this field, without routing their paths. */ - parseIssues: ParseFieldIssuesFn } /** A field validator's success result or errors. Field validators do not provide output data. */ @@ -964,6 +926,9 @@ export type FieldValidateResult = ValidationResult /** * Validates a field's value and reports errors owned by that field. * + * Standard Schema issue arrays can be returned directly, including readonly + * arrays. Issue paths do not route errors to other fields. + * * @typeParam TFormData - Library-managed. Do not specify explicitly. * @typeParam TFieldName - Library-managed. Do not specify explicitly. * @typeParam TFieldValue - Library-managed. Do not specify explicitly. @@ -1118,7 +1083,7 @@ type ValidationErrorValueFromType = unknown extends TError type ValidationErrorInputFromType = [TError] extends [never] ? never - : OneOrMany> + : ReadonlyOneOrMany> /** * Form validation results constrained to inferred issue types, as stored in server form state. diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index a7e1ba92f2..1cb82f269c 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -1,8 +1,4 @@ -import { - isStandardSchema, - parseStandardSchema, - parseStandardSchemaIssues, -} from '../standardSchema.lib' +import { isStandardSchema, parseStandardSchema } from '../standardSchema.lib' import { VALIDATION_OUTPUT, isValidationOutput } from '../validationOutput.lib' import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' import type { InternalFormApi } from '../FormApi/FormApi.lib' @@ -38,11 +34,11 @@ type ServerPipelineValidateContext = { } type FieldValidateContext = Omit< FieldValidatorContext, - 'value' | 'parseIssues' + 'value' > & { scope: 'field' } type FormGroupValidateContext = Omit< FormGroupValidatorContext, - 'value' | 'parseIssues' | 'createErrorMap' | 'createOutput' + 'value' | 'createErrorMap' | 'createOutput' > & { scope: 'group' } export type FormInputContext = Omit type ServerFormInputContext = Omit @@ -148,13 +144,6 @@ function getPredicateContext( } } -/** Parses Standard Schema issues as errors owned directly by a field. */ -export function parseFieldIssues( - issues: Parameters['parseIssues']>[0], -) { - return parseStandardSchemaIssues(issues, undefined, 'field') -} - export const ABORTED_CALL = Symbol('ABORTED_CALL') export const THROWN_ERROR = Symbol('THROWN_ERROR') diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index b914809e5f..65cb22ce5a 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -1,16 +1,11 @@ import { createOutput } from '../validationOutput.lib' -import { createErrorMap } from '../validation.public' -import { - isStandardSchema, - parseStandardSchema, - parseStandardSchemaIssues, -} from '../standardSchema.lib' +import { createErrorMapFactory } from '../createErrorMap.lib' +import { isStandardSchema, parseStandardSchema } from '../standardSchema.lib' import { isPromiseLike } from '../utils.lib' import { isErrorResult } from './errors.lib' import { createValidatorAbortContext, normalizeValidatorResult, - parseFieldIssues, } from './execution.lib' import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' import type { InternalFormApi } from '../FormApi/FormApi.lib' @@ -287,10 +282,8 @@ export function runFormMountValidatorPipeline({ signal, formApi, value: formApi.state.values, - createErrorMap, + createErrorMap: createErrorMapFactory(formApi.state.values), createOutput, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, formApi.state.values, 'form'), }), scope: 'form', onResult, @@ -317,7 +310,6 @@ export function runFieldMountValidatorPipeline({ formApi: fieldApi.form as never, fieldApi: fieldApi as never, value: fieldApi.value, - parseIssues: parseFieldIssues, }), scope: 'field', onResult, @@ -347,10 +339,8 @@ export function runGroupMountValidatorPipeline({ groupApi: groupApi as never, triggerFieldApi: undefined, value: groupApi.value, - createErrorMap, + createErrorMap: createErrorMapFactory(groupApi.value), createOutput, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, groupApi.value, 'form'), }), scope: 'form', onResult, diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts index e90bc50387..1e4682a0d5 100644 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -1,6 +1,5 @@ import { createOutput } from '../validationOutput.lib' -import { createErrorMap } from '../validation.public' -import { parseStandardSchemaIssues } from '../standardSchema.lib' +import { createErrorMapFactory } from '../createErrorMap.lib' import { isNotNil } from '../utils.lib' import { isErrorResult } from './errors.lib' import { @@ -9,7 +8,6 @@ import { executeValidator, isFieldValidateContext, isServerValidateContext, - parseFieldIssues, runMaybeDebouncedValidator, shouldRunValidator, } from './execution.lib' @@ -258,12 +256,8 @@ export function runFormValidatorPipeline({ formApi: ctx.formApi, signal: ctx.signal, value: ctx.formApi.state.values, - createErrorMap, + createErrorMap: createErrorMapFactory(ctx.formApi.state.values), createOutput, - parseIssues: ( - issues: Parameters[0], - ) => - parseStandardSchemaIssues(issues, ctx.formApi.state.values, 'form'), } if (!isFieldValidateContext(ctx)) { @@ -337,7 +331,6 @@ export function runFieldValidatorPipeline({ signal: ctx.signal, fieldApi: context.fieldApi, value: context.fieldApi.value, - parseIssues: parseFieldIssues, } }, scope: 'field', diff --git a/packages/form-core/tests/FormApi/submission-handling.spec.ts b/packages/form-core/tests/FormApi/submission-handling.spec.ts index b66e2bd3e0..5540773a60 100644 --- a/packages/form-core/tests/FormApi/submission-handling.spec.ts +++ b/packages/form-core/tests/FormApi/submission-handling.spec.ts @@ -198,11 +198,11 @@ describe('form - submission handling', () => { }) const form = new InternalFormApi({ defaultValues: { name: '' }, - onSubmit: ({ value, parseIssues }) => { + onSubmit: ({ value, createErrorMap }) => { const result = schema.safeParse(value) if (!result.success) { - return parseIssues(result.error.issues) + return createErrorMap(result.error.issues) } return null diff --git a/packages/form-core/tests/FormApi/validation.spec.ts b/packages/form-core/tests/FormApi/validation.spec.ts index 2ca600b9a8..2a033fa399 100644 --- a/packages/form-core/tests/FormApi/validation.spec.ts +++ b/packages/form-core/tests/FormApi/validation.spec.ts @@ -95,7 +95,7 @@ describe('form - validation', () => { const result = schema.safeParse(context.value) if (!result.success) { - return context.parseIssues(result.error.issues) + return result.error.issues } return null @@ -122,11 +122,11 @@ describe('form - validation', () => { defaultValues: { user: { name: '' } }, validators: [ { - run: ({ value, parseIssues }) => { + run: ({ value, createErrorMap }) => { const result = schema.safeParse(value) if (!result.success) { - return parseIssues(result.error.issues) + return createErrorMap(result.error.issues) } return null diff --git a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index 74c906f0ff..d0b66e010d 100644 --- a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts @@ -2030,11 +2030,11 @@ describe('FormGroupApi', () => { validators: [ { triggers: [], - run: ({ value, parseIssues }) => { + run: ({ value, createErrorMap }) => { const result = schema.safeParse(value) if (!result.success) { - return parseIssues(result.error.issues) + return createErrorMap(result.error.issues) } return null diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts index dbff7c12b9..3a7e082950 100644 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -122,10 +122,10 @@ describe('runFormValidatorPipeline', () => { const issues = [ { message: 'Invalid name', path: ['users', 0, 'name'] as const }, ] - const run = vi.fn(({ triggerFieldApi, value, parseIssues }) => { + const run = vi.fn(({ triggerFieldApi, value, createErrorMap }) => { expect(triggerFieldApi).toBe(field) expect(value).toBe(formApi.state.values) - expect(parseIssues(issues)).toEqual({ + expect(createErrorMap(issues)).toEqual({ form: issues, fields: { 'users[0].name': issues }, }) @@ -154,7 +154,7 @@ describe('runFormValidatorPipeline', () => { expect(fieldContext.fieldApi).toBe(field) expect(fieldContext).not.toHaveProperty('triggerFieldApi') expect(fieldContext.value).toBe(formApi.state.values) - expect(fieldContext.parseIssues(issues)).toEqual({ + expect(fieldContext.createErrorMap(issues)).toEqual({ form: issues, fields: { 'users[0].name': issues }, }) diff --git a/packages/form-core/tests/validation-public.test.ts b/packages/form-core/tests/validation-public.test.ts index be414c1ecf..149b7bb053 100644 --- a/packages/form-core/tests/validation-public.test.ts +++ b/packages/form-core/tests/validation-public.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import z from 'zod' import { - createErrorMap, createErrorVisibility, createValidator, createValidators, @@ -62,41 +61,4 @@ describe('validation public helpers', () => { expect(createErrorVisibility(visibility)).toBe(visibility) }) - - it('creates mutable validation error maps', () => { - const errors = createErrorMap<{ name: string; age: number }>() - - expect(errors).toEqual({ fields: {} }) - errors.fields.name = undefined - errors.fields.age = 'Age is required' - errors.form = 'Form is invalid' - - expect(errors).toEqual({ - form: 'Form is invalid', - fields: { name: undefined, age: 'Age is required' }, - }) - }) - - it('returns the prefilled validation error map', () => { - const initial = { - form: 'Form is invalid', - fields: { name: 'Name is required' }, - } - - const errors = createErrorMap(initial) - - expect(errors).toBe(initial) - }) - - it('preserves falsy form errors in the initial error map', () => { - const initial = { - form: '', - fields: {}, - } - - const errors = createErrorMap(initial) - - expect(errors).toBe(initial) - expect(errors).toHaveProperty('form', '') - }) }) diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index fd5f630260..c14596435c 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -6,10 +6,10 @@ import { createValidator, createValidators, formOptions, - createErrorMap as importCreateErrorMap, } from '../src' import type { AnyFieldApi, + CreateSubmitErrorMapFn, ErrorVisibilityFieldState, FieldApiOptions, FieldErrors, @@ -23,7 +23,6 @@ import type { FormSubmitInvalidContext, FormValidators, OnSubmitError, - ParseSubmitIssuesFn, ReusableErrorVisibilityState, StandardSchemaV1Issue, ToFieldError, @@ -31,7 +30,6 @@ import type { ToFormGroupErrorTypes, ToFormGroupOutputs, ToFormOutputs, - ValidationErrorMap, ValidationIssue, } from '../src' @@ -501,35 +499,6 @@ describe('ErrorVisibility', () => { }) describe('createErrorMap', () => { - it('creates a typed error map shape', () => { - type ErrorMapFormData = { - name: string - user: { - age: number - } - } - - const errors = importCreateErrorMap() - const initial: ValidationErrorMap = { - form: 'Initial form error', - fields: { name: 'Initial name error' }, - } - - expectTypeOf(errors).toEqualTypeOf>() - expectTypeOf(importCreateErrorMap(initial)).toEqualTypeOf< - ValidationErrorMap - >() - expectTypeOf( - importCreateErrorMap({ form: 'Partial form error' }), - ).toEqualTypeOf>() - - errors.form = 'Form error' - errors.fields.name = 'Name is required' - errors.fields['user.age'] = { message: 'Age is required' } - // @ts-expect-error Error map fields must use valid form keys. - errors.fields.missing = 'Missing' - }) - it('infers the error map shape from form validator context', () => { const validators = defineFormValidators([ { @@ -980,7 +949,7 @@ describe('FormErrors', () => { it('should infer form errors from parsed Standard Schema issues', () => { const validators = defineFormValidators([ { - run: ({ parseIssues }) => parseIssues([{ message: '' }]), + run: ({ createErrorMap }) => createErrorMap([{ message: '' }]), triggers: [], }, ]) @@ -1100,7 +1069,7 @@ describe('FormErrors', () => { }) it('should infer submit errors from parsed Standard Schema issues', () => { - type SubmitReturn = ReturnType> + type SubmitReturn = ReturnType> expectTypeOf>().toEqualTypeOf< Array @@ -1108,8 +1077,8 @@ describe('FormErrors', () => { new InternalFormApi({ defaultValues: { name: '' }, - onSubmit: ({ parseIssues }) => { - return parseIssues([{ message: '' }]) + onSubmit: ({ createErrorMap }) => { + return createErrorMap([{ message: '' }]) }, }) }) @@ -1316,15 +1285,15 @@ describe('FieldErrors', () => { it('should infer field errors from parsed Standard Schema issues', () => { const fieldValidators = defineFieldValidators([ { - run: ({ parseIssues }) => parseIssues([{ message: '' }]), + run: (): ReadonlyArray => [{ message: '' }], triggers: [], }, ]) const formValidators = defineFormValidators([ { - run: ({ parseIssues }) => - parseIssues([{ message: '', path: ['name'] }]), + run: ({ createErrorMap }) => + createErrorMap([{ message: '', path: ['name'] }]), triggers: [], }, ]) @@ -1353,8 +1322,8 @@ describe('FieldErrors', () => { it('should infer field errors from group parsed Standard Schema issues', () => { const groupValidators = defineGroupValidators([ { - run: ({ parseIssues }) => - parseIssues([{ message: '', path: ['name'] }]), + run: ({ createErrorMap }) => + createErrorMap([{ message: '', path: ['name'] }]), triggers: [], }, ]) From d8475da255715f6583d78516a96abce94c1ba6ef Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:41:37 +0200 Subject: [PATCH 03/43] feat: create internal error map factory fn --- .../src/validation/createErrorMap.lib.ts | 45 +++++ .../form-core/src/validation/state.public.ts | 174 ++++++++++++++++++ .../validation/createErrorMap.lib.spec.ts | 146 +++++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 packages/form-core/src/validation/createErrorMap.lib.ts create mode 100644 packages/form-core/src/validation/state.public.ts create mode 100644 packages/form-core/tests/validation/createErrorMap.lib.spec.ts diff --git a/packages/form-core/src/validation/createErrorMap.lib.ts b/packages/form-core/src/validation/createErrorMap.lib.ts new file mode 100644 index 0000000000..2d44e2cb5a --- /dev/null +++ b/packages/form-core/src/validation/createErrorMap.lib.ts @@ -0,0 +1,45 @@ +import { prefixSchemaToErrors } from '../standardSchema.lib' +import { isReadonlyArray } from '../utils.lib' +import type { StandardSchemaV1Issue } from '../standardSchema.public' +import type { + CreateErrorMapFn, + ValidationErrorMap, + ValidationFailure, + ValidationFailureItem, +} from '../validation/state.public' + +// TODO migrate to a proper file sometime. + +export function createErrorMapFactory( + value: TFormData, +): CreateErrorMapFn { + return (( + initial: + | Partial> + | ReadonlyArray = {}, + ) => { + if (isReadonlyArray(initial)) { + return { + form: initial.slice(), + fields: prefixSchemaToErrors(initial, value), + } + } + + let form: ValidationFailureItem[] = [] + if (isReadonlyArray(initial.form)) { + form = initial.form.slice() + } else if (initial.form) { + form = [initial.form] + } + + return { + form, + fields: Object.fromEntries( + Object.entries(initial.fields ?? {}).map(([name, error]) => [ + name, + isReadonlyArray(error) ? error.slice() : error, + ]), + ), + } + }) as CreateErrorMapFn +} diff --git a/packages/form-core/src/validation/state.public.ts b/packages/form-core/src/validation/state.public.ts new file mode 100644 index 0000000000..1ae5bbc5b4 --- /dev/null +++ b/packages/form-core/src/validation/state.public.ts @@ -0,0 +1,174 @@ +import type { DeepKeys } from '../deep-keys.public' +import type { + StandardSchemaV1, + StandardSchemaV1Issue, +} from '../standardSchema.public' +import type { OneOrMany, ReadonlyOneOrMany } from '../types.public' +import type { VALIDATION_OUTPUT } from '../validationOutput.lib' + +export interface ValidationIssue { + message: string +} + +export interface ValidationOutput { + readonly [VALIDATION_OUTPUT]: TOutput +} + +export type ValidationFailureItem = ValidationIssue | string +export type ValidationFailure< + TItem extends ValidationFailureItem = ValidationFailureItem, +> = ReadonlyOneOrMany +export type ValidationSuccess = + null | undefined | false | void | ValidationOutput + +export type ValidationFieldMap = Partial< + Record, TError> +> + +export interface ValidationErrorMap< + TFormData, + TFormError, + TFieldError = TFormError, +> { + form?: TFormError + fields: ValidationFieldMap +} + +export interface CreatedErrorMap { + form: Array + fields: ValidationFieldMap> +} + +export type ParsedStandardSchemaIssues = CreatedErrorMap< + TFormData, + StandardSchemaV1Issue +> + +type ErrorMapItem = unknown extends TError + ? ValidationFailureItem + : TError extends ReadonlyArray + ? ErrorMapItem + : TError extends string + ? string + : TError + +type InferredErrorMapItem = [ErrorMapItem] extends [never] + ? ValidationFailureItem + : ErrorMapItem + +export interface CreateErrorMapFn { + < + TFormError extends ValidationFailure = never, + TFieldErrors extends ValidationFieldMap = {}, + >(initial?: { + form?: TFormError + fields?: { + [TName in keyof TFieldErrors]: TName extends DeepKeys + ? TFieldErrors[TName] + : never + } + }): CreatedErrorMap< + TFormData, + InferredErrorMapItem + > + ( + issues: ReadonlyArray, + ): ParsedStandardSchemaIssues +} + +export type CreateOutputFn = ( + value: TOutput, +) => ValidationOutput + +// ============================ +// Error state + +export interface ScopedErrorTypes< + out TSelfError = ValidationIssue, + out TSubfieldsError = ValidationIssue, +> { + readonly selfError: TSelfError + readonly subfieldError: TSubfieldsError +} + +type ErrorTarget = 'self' | 'subfield' + +type NormalizeFailure = unknown extends TError + ? ValidationIssue + : TError extends ValidationSuccess + ? never + : TError extends ReadonlyArray + ? NormalizeFailure + : TError extends string + ? ValidationIssue + : TError extends ValidationIssue + ? TError + : never + +type GetDirectError< + TError, + TTarget extends ErrorTarget, +> = TTarget extends 'self' ? TError : never + +type GetMappedResultError< + TResult extends { fields: unknown }, + TTarget extends ErrorTarget, +> = TTarget extends 'self' + ? TResult extends { form?: infer TFormError } + ? NormalizeFailure + : never + : NormalizeFailure + +type GetFailureResultError< + TResult, + TTarget extends ErrorTarget, +> = TResult extends ValidationIssue + ? GetDirectError + : TResult extends { fields: unknown } + ? GetMappedResultError + : GetDirectError, TTarget> + +type GetResultError< + TResult, + TTarget extends ErrorTarget, +> = unknown extends TResult + ? ValidationIssue + : TResult extends ValidationSuccess + ? never + : GetFailureResultError + +type GetRunnerError = unknown extends TRun + ? ValidationIssue + : TRun extends StandardSchemaV1 + ? StandardSchemaV1Issue + : TRun extends (...args: any) => infer TResult + ? GetResultError, TTarget> + : never + +type GetValidatorError< + TValidator, + TTarget extends ErrorTarget, +> = unknown extends TValidator + ? ValidationIssue + : TValidator extends { run: infer TRun } + ? GetRunnerError + : never + +type BroadValidators = Array<{ + run: StandardSchemaV1 | ((...args: any) => unknown) +}> + +type GetValidatorsError = + // Unlike unknown (omitted validators), an explicit any keeps basic errors. + 0 extends 1 & TValidators + ? ValidationIssue + : TValidators extends ReadonlyArray + ? BroadValidators extends TValidators + ? ValidationIssue + : GetValidatorError + : never + +export type GetFormErrorTypes = ScopedErrorTypes< + GetValidatorsError, + GetValidatorsError +> diff --git a/packages/form-core/tests/validation/createErrorMap.lib.spec.ts b/packages/form-core/tests/validation/createErrorMap.lib.spec.ts new file mode 100644 index 0000000000..d89278e1ff --- /dev/null +++ b/packages/form-core/tests/validation/createErrorMap.lib.spec.ts @@ -0,0 +1,146 @@ +import { assert, describe, expect, it } from 'vitest' +import { createErrorMapFactory } from '../../src/validation/createErrorMap.lib' +import type { StandardSchemaV1Issue } from '../../src/standardSchema.public' + +describe('createErrorMap', () => { + it.each([ + { form: 'Check the form', expected: ['Check the form'] }, + { + form: { message: 'Invalid', code: 'custom' }, + expected: [{ message: 'Invalid', code: 'custom' }], + }, + ])('normalizes a single form error: $form', ({ form, expected }) => { + const createErrorMap = createErrorMapFactory({ name: '' }) + + const map = createErrorMap({ form }) + + expect(map).toEqual({ form: expected, fields: {} }) + }) + + it('accepts single and multiple field errors without a form error', () => { + const createErrorMap = createErrorMapFactory({ name: '', age: 0 }) + + const map = createErrorMap({ + fields: { name: 'Required', age: ['Too young', 'Must be an adult'] }, + }) + + expect(map).toEqual({ + form: [], + fields: { name: 'Required', age: ['Too young', 'Must be an adult'] }, + }) + }) + + it('allows editing errors without changing the supplied readonly containers', () => { + const createErrorMap = createErrorMapFactory({ name: '', age: 0 }) + const issue = { message: 'Invalid', code: 'custom' } + // While `expect(old).not.toBe(new)` reads better, + // we need to verify that + // 1. It's shallow copied + // 2. It doesn't modify the input while doing so + const initial = Object.freeze({ + form: Object.freeze([issue]), + fields: Object.freeze({ name: Object.freeze([issue]), age: issue }), + }) + + const map = createErrorMap(initial) + const nameErrors = map.fields.name + assert(Array.isArray(nameErrors)) + nameErrors.push({ message: 'Too short', code: 'length' }) + map.form.push({ message: 'Check the form', code: 'form' }) + map.fields.age = { message: 'Too young', code: 'age' } + + expect(map).toEqual({ + form: [issue, { message: 'Check the form', code: 'form' }], + fields: { + name: [issue, { message: 'Too short', code: 'length' }], + age: { message: 'Too young', code: 'age' }, + }, + }) + expect(initial).toEqual({ + form: [issue], + fields: { name: [issue], age: issue }, + }) + }) + + it.each([ + { scenario: 'omitted errors', initial: undefined }, + { scenario: 'an empty error map', initial: {} }, + ])( + 'keeps separately created maps independent with $scenario', + ({ initial }) => { + const createErrorMap = createErrorMapFactory({ name: '' }) + const first = createErrorMap(initial) + const second = createErrorMap(initial) + + first.form.push({ message: 'Invalid form' }) + first.fields.name = { message: 'Required' } + + expect(first).toEqual({ + form: [{ message: 'Invalid form' }], + fields: { name: { message: 'Required' } }, + }) + expect(second).toEqual({ form: [], fields: {} }) + }, + ) + + it('keeps maps created from an empty schema issue list independent', () => { + const createErrorMap = createErrorMapFactory({ name: '' }) + const issues: Array = [] + const first = createErrorMap(issues) + const second = createErrorMap(issues) + + first.form.push({ message: 'Invalid form' }) + first.fields.name = { message: 'Required' } + + expect(first).toEqual({ + form: [{ message: 'Invalid form' }], + fields: { name: { message: 'Required' } }, + }) + expect(second).toEqual({ form: [], fields: {} }) + expect(issues).toEqual([]) + }) + + it('keeps all schema issues in the form summary and groups nested field issues', () => { + const createErrorMap = createErrorMapFactory({ users: [{ name: '' }] }) + const rootIssue: StandardSchemaV1Issue = { message: 'Invalid form' } + const required: StandardSchemaV1Issue = { + message: 'Required', + path: ['users', { key: '0' }, 'name'], + } + const tooShort: StandardSchemaV1Issue = { + message: 'Too short', + path: ['users', 0, 'name'], + } + + const map = createErrorMap([rootIssue, required, tooShort]) + + expect(map).toEqual({ + form: [rootIssue, required, tooShort], + fields: { 'users[0].name': [required, tooShort] }, + }) + }) + + it('allows editing schema field errors independently of the summary and input', () => { + const createErrorMap = createErrorMapFactory({ name: '' }) + const issue: StandardSchemaV1Issue = { + message: 'Required', + path: ['name'], + } + const issues = Object.freeze([issue]) + const map = createErrorMap(issues) + + const nameErrors = map.fields.name + assert(Array.isArray(nameErrors)) + nameErrors.push({ message: 'Too short' }) + + expect(map.form).toEqual([issue]) + + map.form.push({ message: 'Check the form' }) + + expect(map).toEqual({ + form: [issue, { message: 'Check the form' }], + fields: { name: [issue, { message: 'Too short' }] }, + }) + expect(issues).toEqual([issue]) + }) +}) From 0ee06ab903f09543500c696dbca6ca3d7061a8de Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:40:05 +0200 Subject: [PATCH 04/43] feat: add Awaitable helper type --- packages/form-core/src/types.public.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/form-core/src/types.public.ts b/packages/form-core/src/types.public.ts index 066f6c910b..e684fccb9f 100644 --- a/packages/form-core/src/types.public.ts +++ b/packages/form-core/src/types.public.ts @@ -33,6 +33,11 @@ export interface FieldUpdateOptions { export type OneOrMany = TValue | Array export type ReadonlyOneOrMany = TValue | ReadonlyArray +/** + * A value that may or may not be a promise. Compatible with `await`. + */ +export type Awaitable = TReturn | Promise + /** * A field API that preserves its value type while erasing its name, error, and * owning form types. From 8eef3a584ba2b61cb76a7e05719c330cc45dabb4 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:45:29 +0200 Subject: [PATCH 05/43] feat: create new types of formOptions --- .../src/FormApi/errorVisibility.public.ts | 39 + .../src/FormApi/formListeners.public.ts | 43 + .../src/FormApi/formOptions.public.ts | 50 + .../src/FormApi/formServerState.public.ts | 42 + .../src/FormApi/formSubmit.public.ts | 212 ++++ .../src/validation/createErrorMap.lib.ts | 55 +- .../src/validation/formValidators.public.ts | 511 ++++++++++ .../src/validation/parseIssues.lib.ts | 19 + .../form-core/src/validation/state.public.ts | 70 +- .../FormApi/formOptions.public.test-d.ts | 915 ++++++++++++++++++ .../validation/createErrorMap.lib.spec.ts | 70 +- .../tests/validation/state.public.test-d.ts | 85 ++ 12 files changed, 1980 insertions(+), 131 deletions(-) create mode 100644 packages/form-core/src/FormApi/errorVisibility.public.ts create mode 100644 packages/form-core/src/FormApi/formListeners.public.ts create mode 100644 packages/form-core/src/FormApi/formOptions.public.ts create mode 100644 packages/form-core/src/FormApi/formServerState.public.ts create mode 100644 packages/form-core/src/FormApi/formSubmit.public.ts create mode 100644 packages/form-core/src/validation/formValidators.public.ts create mode 100644 packages/form-core/src/validation/parseIssues.lib.ts create mode 100644 packages/form-core/tests/FormApi/formOptions.public.test-d.ts create mode 100644 packages/form-core/tests/validation/state.public.test-d.ts diff --git a/packages/form-core/src/FormApi/errorVisibility.public.ts b/packages/form-core/src/FormApi/errorVisibility.public.ts new file mode 100644 index 0000000000..18c00ba345 --- /dev/null +++ b/packages/form-core/src/FormApi/errorVisibility.public.ts @@ -0,0 +1,39 @@ +import type { + ScopedErrorTypes, + ToFormErrorTypes, +} from '../validation/state.public' +import type { FormState } from './FormApi.public' + +export interface ErrorVisibilitySubfieldsMeta { + isEveryPristine: boolean + isSomeDirty: boolean + isSomeTouched: boolean + isSomeValidating: boolean +} + +export interface ErrorVisibilityFieldMeta { + isTouched: boolean + isSelfTouched: boolean + isDirty: boolean + isSelfDirty: boolean + isPristine: boolean + isDefaultValue: boolean + isBlurred: boolean + isValidating: boolean + isSelfValidating: boolean + subfields: ErrorVisibilitySubfieldsMeta +} + +export interface ErrorVisibilityFieldState { + value: any + meta: ErrorVisibilityFieldMeta +} + +export interface ErrorVisibilityContext { + state: FormState> + fieldState: ErrorVisibilityFieldState +} + +export type ErrorVisibility = ( + context: ErrorVisibilityContext, +) => boolean diff --git a/packages/form-core/src/FormApi/formListeners.public.ts b/packages/form-core/src/FormApi/formListeners.public.ts new file mode 100644 index 0000000000..8b222dd6f3 --- /dev/null +++ b/packages/form-core/src/FormApi/formListeners.public.ts @@ -0,0 +1,43 @@ +import type { AnyFieldApi } from '../FieldApi/FieldApi.public' +import type { Listener } from '../listeners.public' +import type { + ClientSideEvents, + PreprocessEvents, +} from '../new-validation.public' +// TODO finalize migration +import type { FormErrorTypes } from '../validation.public' +import type { + ScopedErrorTypes, + ToFormErrorTypes, +} from '../validation/state.public' +import type { FormApi } from './FormApi.public' + +export type FormListenerTriggers = ClientSideEvents | PreprocessEvents | 'reset' + +export interface FormListenerContext< + TFormData, + TFormErrorTypes extends FormErrorTypes, +> { + formApi: FormApi + value: TFormData + triggerFieldApi?: AnyFieldApi +} + +export type FormListenerFn< + TFormData, + TFormErrorTypes extends FormErrorTypes, +> = (context: FormListenerContext) => void + +export type AnyFormListener = FormListener + +export interface FormListener< + TFormData, + TFormErrorTypes extends FormErrorTypes, +> extends Listener { + run: FormListenerFn +} + +export type FormListeners< + TFormData, + TFormError extends ScopedErrorTypes, +> = Array>> diff --git a/packages/form-core/src/FormApi/formOptions.public.ts b/packages/form-core/src/FormApi/formOptions.public.ts new file mode 100644 index 0000000000..39fe4a9209 --- /dev/null +++ b/packages/form-core/src/FormApi/formOptions.public.ts @@ -0,0 +1,50 @@ +import type { + GetFormErrorTypes, + ToFormErrorTypes, +} from '../validation/state.public' +import type { ErrorVisibility } from './errorVisibility.public' +import type { FormListeners } from './formListeners.public' +import type { ServerFormState } from './formServerState.public' +import type { + FormOptionsOnSubmitFn, + GetValidatorOutputs, +} from './formSubmit.public' +import type { FormOptionsValidators } from '../validation/formValidators.public' + +type TryGetValidatorOutputs = + TValidators extends ReadonlyArray + ? GetValidatorOutputs + : [] + +export interface FormOptions< + TFormData, + TValidatorConfig extends unknown[], + TValidators, +> { + defaultValues: TFormData + formId?: string + errorVisibility?: ErrorVisibility> + validators?: TValidators & + FormOptionsValidators, TValidatorConfig> + listeners?: FormListeners< + NoInfer, + NoInfer> + > + serverState?: NoInfer> | null + // TODO FormErrorTypes + onSubmit?: FormOptionsOnSubmitFn< + NoInfer, + NoInfer>, + NoInfer> + > +} + +export type FormOptionsFn = < + TFormData, + const TValidatorConfig extends unknown[], + TValidators, +>( + options: FormOptions, +) => FormOptions + +export const formOptions: FormOptionsFn = (opts) => opts diff --git a/packages/form-core/src/FormApi/formServerState.public.ts b/packages/form-core/src/FormApi/formServerState.public.ts new file mode 100644 index 0000000000..b46fe1c57f --- /dev/null +++ b/packages/form-core/src/FormApi/formServerState.public.ts @@ -0,0 +1,42 @@ +import type { ServerSideEvents } from '../new-validation.public' +// TODO finalize migration +import type { FormValidateResultFromErrorTypes } from '../validation.public' +import type { + GetFormErrorTypes, + ScopedErrorTypes, + ToFormErrorTypes, +} from '../validation/state.public' + +type ServerValidator = unknown extends TValidator + ? TValidator + : TValidator extends { triggers?: ReadonlyArray } + ? Extract< + TTrigger, + ServerSideEvents | { trigger: ServerSideEvents } + > extends never + ? never + : TValidator + : never + +type ServerValidators = + TValidators extends ReadonlyArray + ? Array> + : TValidators + +type ServerFormErrorTypes = unknown extends TValidators + ? ScopedErrorTypes + : GetFormErrorTypes> + +export interface ServerFormState { + values: TFormData | undefined + validationResults: Array<{ + validatorIndex: number + result: FormValidateResultFromErrorTypes< + TFormData, + ToFormErrorTypes> + > + output: unknown | null + hasOutput?: boolean + }> + submissionAttempts: number +} diff --git a/packages/form-core/src/FormApi/formSubmit.public.ts b/packages/form-core/src/FormApi/formSubmit.public.ts new file mode 100644 index 0000000000..acc4a03e54 --- /dev/null +++ b/packages/form-core/src/FormApi/formSubmit.public.ts @@ -0,0 +1,212 @@ +import type { StandardSchemaV1 } from '../standardSchema.public' +import type { PARSED_ISSUES } from '../validation/parseIssues.lib' +import type { + CreateErrorMapFn, + CreatedErrorMap, + FormValidationFailure, + ParseIssuesFn, + ScopedErrorTypes, + ToFormErrorTypes, + ValidationFailureItem, +} from '../validation/state.public' +import type { VALIDATION_OUTPUT } from '../validationOutput.lib' +import type { + CreateValidationErrorFn, + FormApi, + OnSubmitError, +} from './FormApi.public' +import type { ParseValidatorCondition } from '../validation/formValidators.public' +import type { Awaitable } from '../types.public' + +/** + * The result of `createOutput`. Return it from a validator to store its data + * during submission. + * + * @typeParam TOutput - Library-managed. Do not specify explicitly. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * validators: [ + * { + * run: ({ createOutput, value }) => { + * return createOutput({ displayName: value.name.trim() }) + * }, + * }, + * { + * // Schemas pass their output data by default. + * run: mySchema, + * }, + * ], + * onSubmit: ({ validatorOutputs }) => { + * // Outputs follow the same order as the validators array. + * const [customData, schemaOutput] = validatorOutputs + * console.log(customData.displayName) + * }, + * }) + * ``` + */ +export interface ValidationOutput { + /** The data supplied to `createOutput`. */ + readonly [VALIDATION_OUTPUT]: TOutput +} + +type UnwrapValidatorFunction = TOutput extends { + readonly [PARSED_ISSUES]: true +} + ? never + : TOutput extends ValidationOutput + ? T + : undefined + +// Make sure TSkipOnSubmit is distributed so that we get boolean -> possibly undefined +type GetValidatorRunOutput = TSkipOnSubmit extends true + ? undefined + : TRun extends StandardSchemaV1 + ? TOutput + : TRun extends (...args: any) => infer TOutput + ? UnwrapValidatorFunction> + : undefined + +type GetValidatorOutput = TValidator extends { run: infer TRun } + ? GetValidatorRunOutput< + TRun, + TValidator extends { skipOnSubmit: infer TCondition } + ? ParseValidatorCondition + : false + > + : undefined + +/** + * Infers form validators' submission output types in configuration order. + * + * Standard Schemas contribute their parsed output types. Validator functions + * contribute the payload types of their `createOutput` results, including + * results returned in promises. Successful returns without an output contribute + * `undefined`. + * + * `skipOnSubmit: true` makes an entry `undefined`. A callback or `boolean` + * condition allows `undefined` alongside the validator's output type. + * + * @typeParam TValidators - Library-managed. Do not specify explicitly. + */ +export type GetValidatorOutputs> = { + [K in keyof TValidators]: GetValidatorOutput +} + +type SubmitErrorMap = CreatedErrorMap< + TFormData, + ValidationFailureItem +> +type ParsedSubmitIssues = ReturnType> + +/** + * Builds a mutable error map to return from `onSubmit` and report validation errors. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ +export type CreateSubmitErrorMapFn = ( + initial?: Parameters>[0], +) => SubmitErrorMap & OnSubmitError> + +/** + * Converts Standard Schema issues into a mutable error map to return from `onSubmit`. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ +export type ParseSubmitIssuesFn = ( + issues: Parameters>[0], +) => ParsedSubmitIssues & + OnSubmitError> + +/** + * Form values, validator outputs, and error helpers passed to `onSubmit` after + * submission validation succeeds. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValidatorOutputs - Library-managed. Do not specify explicitly. + * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. + */ +export interface FormSubmitContext< + TFormData, + TValidatorOutputs extends readonly unknown[], + TFormError extends ScopedErrorTypes, +> { + /** The form values for this submission, without schema transformations applied. */ + value: TFormData + /** The form instance handling this submission. */ + formApi: FormApi> + /** + * The validator outputs from this submission. + * + * Standard Schemas provide their output data automatically. Callback + * validators must return a `createOutput` result to provide output data. + * + * Outputs follow the same order as the validators array. + * + * Skipped validators and successful returns without output contribute + * `undefined`. + * + * Outputs from earlier change, blur, or mount validation are not reused. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * validators: [ + * { + * run: ({ createOutput, value }) => { + * return createOutput({ displayName: value.name.trim() }) + * }, + * }, + * { + * // Schemas pass their output data by default. + * run: mySchema, + * }, + * ], + * onSubmit: ({ validatorOutputs }) => { + * // Outputs follow the same order as the validators array. + * const [customData, schemaOutput] = validatorOutputs + * console.log(customData.displayName) + * }, + * }) + * ``` + */ + validatorOutputs: TValidatorOutputs + /** + * Creates a validation error recognized as a submission failure when returned + * from a submission handler. Creating the error alone does not update form or + * field validation state. + */ + createValidationError: CreateValidationErrorFn + /** + * Builds a mutable submission error map from optional initial errors. Return + * the map to apply its form and field errors. An empty map indicates success. + */ + createErrorMap: CreateSubmitErrorMapFn + /** + * Converts Standard Schema issues into a mutable submission error map using + * the submitted values. Return the map to report its form and field errors. + */ + parseIssues: ParseSubmitIssuesFn +} + +/** + * Handles a form submission after submission validation succeeds. + * + * Return a result from `createValidationError`, `createErrorMap`, or `parseIssues` + * to report validation errors from the submission handler. + * + * A returned promise is awaited before submission finishes. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValidatorOutputs - Library-managed. Do not specify explicitly. + */ +export type FormOptionsOnSubmitFn< + TFormData, + TValidatorOutputs extends readonly unknown[], + TFormError extends ScopedErrorTypes, +> = ( + context: FormSubmitContext, +) => Awaitable>> diff --git a/packages/form-core/src/validation/createErrorMap.lib.ts b/packages/form-core/src/validation/createErrorMap.lib.ts index 2d44e2cb5a..547db8b8ad 100644 --- a/packages/form-core/src/validation/createErrorMap.lib.ts +++ b/packages/form-core/src/validation/createErrorMap.lib.ts @@ -1,45 +1,30 @@ -import { prefixSchemaToErrors } from '../standardSchema.lib' import { isReadonlyArray } from '../utils.lib' -import type { StandardSchemaV1Issue } from '../standardSchema.public' import type { CreateErrorMapFn, ValidationErrorMap, ValidationFailure, ValidationFailureItem, -} from '../validation/state.public' +} from './state.public' // TODO migrate to a proper file sometime. -export function createErrorMapFactory( - value: TFormData, -): CreateErrorMapFn { - return (( - initial: - | Partial> - | ReadonlyArray = {}, - ) => { - if (isReadonlyArray(initial)) { - return { - form: initial.slice(), - fields: prefixSchemaToErrors(initial, value), - } - } +export const createErrorMap = (( + initial: Partial> = {}, +) => { + let form: ValidationFailureItem[] = [] + if (isReadonlyArray(initial.form)) { + form = initial.form.slice() + } else if (initial.form) { + form = [initial.form] + } - let form: ValidationFailureItem[] = [] - if (isReadonlyArray(initial.form)) { - form = initial.form.slice() - } else if (initial.form) { - form = [initial.form] - } - - return { - form, - fields: Object.fromEntries( - Object.entries(initial.fields ?? {}).map(([name, error]) => [ - name, - isReadonlyArray(error) ? error.slice() : error, - ]), - ), - } - }) as CreateErrorMapFn -} + return { + form, + fields: Object.fromEntries( + Object.entries(initial.fields ?? {}).map(([name, error]) => [ + name, + isReadonlyArray(error) ? error.slice() : error, + ]), + ), + } +}) as CreateErrorMapFn diff --git a/packages/form-core/src/validation/formValidators.public.ts b/packages/form-core/src/validation/formValidators.public.ts new file mode 100644 index 0000000000..191e723216 --- /dev/null +++ b/packages/form-core/src/validation/formValidators.public.ts @@ -0,0 +1,511 @@ +import type { AnyFieldApi } from '../FieldApi/FieldApi.public' +import type { + ClientSideEvents, + DynamicValidatorTriggers, + PreprocessEvents, + ServerSideEvents, + StaticValidatorTriggers, + ValidatorEvents, + ValidatorTriggers, +} from '../new-validation.public' +import type { StandardSchemaV1 } from '../standardSchema.public' +import type { + CreateErrorMapFn, + CreateOutputFn, + FormValidationResult, + ParseIssuesFn, +} from './state.public' +import type { FormApi } from '../FormApi/FormApi.public' +import type { Awaitable } from '../types.public' + +// =================================== +// `run: () => {}` Context data + +/** + * Shared helpers for returning form validation errors or successful output data. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ +interface BaseFormValidatorContext { + /** + * Builds a mutable error map for the form and its field paths from optional + * initial errors. Return the created map to report its errors. + * + * Returning an empty error map is considered successful validation. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '', email: '' }, + * validators: [ + * { + * run: ({ createErrorMap, value }) => { + * // Return a map directly. + * if (value.name.trim().length > 0) return; + * return createErrorMap({ + * form: 'Please check your details', + * fields: { name: 'Name is required' }, + * }); + * }, + * }, + * { + * run: ({ createErrorMap, value }) => { + * // Collect errors before returning the map. + * const errors = createErrorMap() + * + * if (value.name.trim().length === 0) { + * errors.fields.name = 'Name is required' + * } + * if (value.email.trim().length === 0) { + * errors.fields.email = 'Email is required' + * } + * + * return errors + * }, + * }, + * ], + * }) + * ``` + */ + createErrorMap: CreateErrorMapFn + /** + * Converts Standard Schema issues into a mutable error map. All issues appear + * in `form`, and issues with paths are also grouped in `fields` using the + * values being validated. Return the map to report its errors. + * + * @example + * ```ts + * run: ({ value, parseIssues }) => { + * const result = schema.safeParse(value) + * if (!result.success) { + * return parseIssues(result.error.issues) + * } + * } + * ``` + */ + parseIssues: ParseIssuesFn + /** + * Use this to return custom data when validation succeeds. The data is + * available in `validatorOutputs` during submission or server validation. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * validators: [ + * { + * run: ({ createOutput, value }) => { + * return createOutput({ displayName: value.name.trim() }) + * }, + * }, + * { + * // Schemas pass their output data by default. + * run: mySchema + * } + * ], + * onSubmit: ({ validatorOutputs }) => { + * // Outputs follow the same order as the validators array. + * const [customData, schemaOutput] = validatorOutputs + * console.log(customData.displayName) + * }, + * }) + * ``` + */ + createOutput: CreateOutputFn +} + +/** + * Form values and result helpers passed to a validator during server validation. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TEvents - Library-managed. Do not specify explicitly. + */ +export interface FormValidatorServerContext< + TFormData, + TEvents extends ServerSideEvents, +> extends BaseFormValidatorContext { + /** The complete form values decoded from `FormData` for server validation. */ + value: TFormData + /** + * The event that caused this validation run. + * + * - `'server'`: Form values are being validated on the server. + */ + event: TEvents +} + +/** + * Form values and result helpers passed to a validator during initial validation. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TEvents - Library-managed. Do not specify explicitly. + */ +export interface FormValidatorPreprocessContext< + TFormData, + TEvents extends PreprocessEvents, +> extends BaseFormValidatorContext { + /** The complete form values being validated during initialization. */ + value: TFormData + /** + * The event that caused this validation run. + * + * - `'mount'`: The form is being constructed. + */ + event: TEvents +} + +/** + * Form values, result helpers, and form access passed to a validator for change, + * blur, or submission events. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TEvents - Library-managed. Do not specify explicitly. + */ +export interface FormValidatorClientContext< + TFormData, + TEvents extends ClientSideEvents, +> extends BaseFormValidatorContext { + /** The complete form values being validated in this run. */ + value: TFormData + /** + * The event that caused this validation run. + * + * - `'change'`: A form value changed. + * - `'blur'`: A field was blurred. + * - `'submit'`: A form submission was attempted. + */ + event: TEvents + /** The form instance that owns this validator. */ + formApi: FormApi + /** + * Aborted when this validation run is cancelled. Pass it to asynchronous work + * such as `fetch` to cancel that work too. + */ + signal: AbortSignal + /** + * The field whose change or blur triggered validation. `undefined` when no + * field triggered the run. + */ + triggerFieldApi?: AnyFieldApi +} + +type GetValidatorContext< + TFormData, + TEvents extends ValidatorEvents, +> = TEvents extends infer T + ? T extends ServerSideEvents + ? FormValidatorServerContext + : T extends ClientSideEvents + ? FormValidatorClientContext + : T extends PreprocessEvents + ? FormValidatorPreprocessContext + : never + : never + +/** + * Validates the complete form values with a context selected from its configured events. + * + * @returns Form or field errors, a successful result from `createOutput`, or + * `null`, `undefined`, or `false` for success without output data. The result + * can also be returned in a promise. + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TEvents - Library-managed. Do not specify explicitly. + */ +export type FormValidatorFn = ( + context: GetValidatorContext, +) => Awaitable> + +// ================================== +// Other contexts and functions + +/** + * Current form state passed to `when` and `skipOnSubmit` callbacks. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValue - Library-managed. Do not specify explicitly. + */ +export interface ValidatorPredicateContext { + /** The owning form calling this function. */ + formApi: FormApi + /** + * The field that triggered validation. `undefined` when no field triggered + * the run. + */ + fieldApi?: AnyFieldApi + /** + * The current value when the condition is evaluated. + */ + value: TValue +} + +/** + * Synchronously decides whether a trigger is enabled or submission validation + * is skipped. + * + * @returns For `when`, `true` enables the trigger and `false` disables it. For + * `skipOnSubmit`, `true` skips validation for the submission attempt and + * `false` allows it. Promises are not supported. + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValue - Library-managed. Do not specify explicitly. + */ +type ValidatorPredicateFn = ( + context: ValidatorPredicateContext, +) => boolean + +/** + * Treats callback conditions as variable booleans when inferring validator + * events and submission outputs. + * + * A callback with a literal `true` or `false` return type still becomes + * `boolean`. Other condition types are preserved. + * + * @typeParam TCondition - Library-managed. Do not specify explicitly. + */ +export type ParseValidatorCondition = TCondition extends ( + ...args: any +) => boolean + ? boolean + : TCondition + +// TODO: When wiring up validation, apply fieldErrorTarget to schema issues; +// default to 'field' and always keep root issues in the form, including paths +// normalized to empty. Warn once during configuration in development if a +// callback supplies the option; ignore it for callbacks in every build. +/** + * Provides the function or Standard Schema used to validate complete form values. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFormEvents - Library-managed. Do not specify explicitly. + */ +interface FormValidatorRunner { + /** + * Validates the complete form values with a function or Standard Schema. + * + * A function receives the context for the current event and may return a + * promise. A schema validates the form values and provides its parsed data + * as output on success. + */ + run: + StandardSchemaV1 | FormValidatorFn + + /** + * Selects where this schema's issues with field paths are reported. + * This setting does nothing for callback validators. + * + * - `'field'`: Report issues on their corresponding fields. + * - `'field-and-self'`: Report issues on their fields and the form. + * - `'self'`: Report issues only on the form. + * + * Issues without a path, including paths that normalize to empty, always + * go to the form. This option affects runtime routing only; inferred self + * and subfield error types stay unchanged. + * + * @default 'field' + */ + fieldErrorTarget?: 'field' | 'field-and-self' | 'self' +} + +/** + * Enables or disables a mount or server trigger with a boolean condition. + * + * @typeParam TTrigger - Library-managed. Do not specify explicitly. + */ +type StaticTriggerConfig = { + /** `'mount'` for initial validation, or `'server'` for server validation. */ + trigger: TTrigger & StaticValidatorTriggers + /** `true` enables validation for this trigger; `false` disables it. */ + when: boolean +} + +/** + * Enables a change or blur trigger with a boolean or a callback evaluated for + * each matching event. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TTrigger - Library-managed. Do not specify explicitly. + */ +type DynamicTriggerConfig = { + /** The change or blur event to match before evaluating `when`. */ + trigger: TTrigger & DynamicValidatorTriggers + /** + * Whether validation runs for this trigger. A callback reads the current + * form values and state; return `true` to run validation or `false` to skip it. + */ + when: boolean | ValidatorPredicateFn +} + +// Constraining the TTrigger + Extracting early would kill autocomplete. +// This looks cursed, but it'll have to do. +/** + * Selects an event to run validation, either unconditionally or with a `when` + * condition. + * + * Use a string for an unconditional trigger or an object to add a condition. + * Submission is controlled separately by `skipOnSubmit`. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TTrigger - Library-managed. Do not specify explicitly. + */ +type TriggerConfig = + // Hejlsberg, forgive them; for they know not what they do. + | `${TTrigger & ValidatorTriggers}` + | StaticTriggerConfig + | DynamicTriggerConfig + +// =================================== +// Full validator objects + +/** + * Controls which events run a form validator and how long change and blur + * validation waits. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFormTriggers - Library-managed. Do not specify explicitly. + */ +interface BaseFormValidator { + /** + * Events that run this validator in addition to submission, optionally with + * a `when` condition. + * + * - `'change'`: A form value changed. + * - `'blur'`: A field was blurred. + * - `'mount'`: The form is being constructed. + * - `'server'`: Form values are being validated on the server. + * + * Use a string to enable an event unconditionally, or an object with + * `trigger` and `when` to add a condition. + * + * Submission is controlled separately by `skipOnSubmit`. + * @default [] + * + * @example + * ```ts + * triggers: [ + * 'blur', // Always run on blur + * { + * // Only run on change after a submission attempt + * trigger: 'change', + * when: ({ formApi }) => formApi.state.submissionAttempts > 0 + * }, + * ] + * ``` + */ + triggers?: TriggerConfig[] + /** + * How long change and blur validation waits after the latest matching event + * before it runs. + * + * The delay is measured in milliseconds. Repeated events restart the delay + * for this validator. + * + * @important Mount, server, and submission validation are not delayed. + * + * @default 0 + */ + triggerDebounceMs?: number +} + +type MaybeSubmitEvent = false extends TValidatorConfig + ? 'submit' + : true extends TValidatorConfig + ? never + : 'submit' + +type GetValidatorEvents = + | Extract + | MaybeSubmitEvent> + +type SkipOnSubmitConfig = + (TValidatorConfig & boolean) | ValidatorPredicateFn + +/** + * Validates the complete form values with a function or Standard Schema. + * + * Configure when this validator runs by setting `skipOnSubmit` and `triggers`. + * + * The context a validator function gets is dependent on the chosen triggers and submission behaviour. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValidatorConfig - Library-managed. Do not specify explicitly. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * validators: [ + * { + * triggers: [ + * 'blur', // Always run on blur + * { + * // Only run on change after a submission attempt + * trigger: 'change', + * when: ({ formApi }) => formApi.state.submissionAttempts > 0 + * }, + * ], + * triggerDebounceMs: 300, + * run: ({ value }) => { + * if (value.name.trim().length === 0) { + * return 'Name is required' + * } + * } + * }, + * ], + * }) + * ``` + */ +export interface FormValidator + extends + BaseFormValidator, + FormValidatorRunner< + TFormData, + NoInfer> + > { + /** + * Whether to skip this validator during a client submission attempt, + * independently of its configured triggers. + * + * Pass `true` to skip it, or a synchronous callback that returns `true` to + * skip it for the current attempt. `false` or an omitted option allows + * submission validation. Configured triggers still use their own conditions. + * + * A skipped validator's entry in submission `validatorOutputs` is `undefined`. + * + * @example + * ```ts + * const isDraft = false + * + * formOptions({ + * defaultValues: { email: '' }, + * validators: [ + * { + * skipOnSubmit: isDraft, + * run: ({ value }) => { + * if (!value.email.includes('@)) { + * return 'Enter a valid email' + * } + * } + * }, + * ], + * }) + * ``` + */ + skipOnSubmit?: SkipOnSubmitConfig +} + +/** + * Matches each form validator to its own trigger and submission settings so + * callback values and events are inferred for that validator. + * + * Validator array positions are preserved. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValidatorConfig - Library-managed. Do not specify explicitly. + */ +export type FormOptionsValidators< + TFormData, + TValidatorConfig extends unknown[], +> = { + // Infer trigger names and the skip condition together before contextualizing run. + // Extract keeps skip conditions and unknown (omitted options) out of events. + [K in keyof TValidatorConfig]: FormValidator +} diff --git a/packages/form-core/src/validation/parseIssues.lib.ts b/packages/form-core/src/validation/parseIssues.lib.ts new file mode 100644 index 0000000000..567479fc3f --- /dev/null +++ b/packages/form-core/src/validation/parseIssues.lib.ts @@ -0,0 +1,19 @@ +import { prefixSchemaToErrors } from '../standardSchema.lib' +import type { ParseIssuesFn, ParsedStandardSchemaIssues } from './state.public' + +export const PARSED_ISSUES = Symbol('PARSED_ISSUES') + +// TODO: Add per-call fieldErrorTarget configuration when wiring up validation, +// defaulting path-based issues to fields and always keeping root issues in form. +export function parseIssuesFactory( + value: TFormData, +): ParseIssuesFn { + return (issues) => ({ + [PARSED_ISSUES]: true, + form: issues.slice(), + fields: prefixSchemaToErrors( + issues, + value, + ) as ParsedStandardSchemaIssues['fields'], + }) +} diff --git a/packages/form-core/src/validation/state.public.ts b/packages/form-core/src/validation/state.public.ts index 1ae5bbc5b4..824bc727ef 100644 --- a/packages/form-core/src/validation/state.public.ts +++ b/packages/form-core/src/validation/state.public.ts @@ -4,7 +4,10 @@ import type { StandardSchemaV1Issue, } from '../standardSchema.public' import type { OneOrMany, ReadonlyOneOrMany } from '../types.public' +// TODO finalize migration +import type { FormErrorTypes } from '../validation.public' import type { VALIDATION_OUTPUT } from '../validationOutput.lib' +import type { PARSED_ISSUES } from './parseIssues.lib' export interface ValidationIssue { message: string @@ -34,15 +37,23 @@ export interface ValidationErrorMap< fields: ValidationFieldMap } +export type FormValidationFailure = + ValidationFailure | ValidationErrorMap + +export type FormValidationResult = + FormValidationFailure | ValidationSuccess + export interface CreatedErrorMap { form: Array fields: ValidationFieldMap> } -export type ParsedStandardSchemaIssues = CreatedErrorMap< +export interface ParsedStandardSchemaIssues extends CreatedErrorMap< TFormData, StandardSchemaV1Issue -> +> { + readonly [PARSED_ISSUES]: true +} type ErrorMapItem = unknown extends TError ? ValidationFailureItem @@ -56,26 +67,31 @@ type InferredErrorMapItem = [ErrorMapItem] extends [never] ? ValidationFailureItem : ErrorMapItem -export interface CreateErrorMapFn { - < - TFormError extends ValidationFailure = never, - TFieldErrors extends ValidationFieldMap = {}, - >(initial?: { - form?: TFormError - fields?: { - [TName in keyof TFieldErrors]: TName extends DeepKeys - ? TFieldErrors[TName] - : never - } - }): CreatedErrorMap< - TFormData, - InferredErrorMapItem - > - ( - issues: ReadonlyArray, - ): ParsedStandardSchemaIssues +type ValidateFieldsMap< + TFormData, + TFieldErrors extends ValidationFieldMap, +> = { + // TODO why do we need this double layering? + [TName in keyof TFieldErrors]: TName extends DeepKeys + ? TFieldErrors[TName] + : never } +export type CreateErrorMapFn = < + TFormError extends ValidationFailure = never, + TFieldErrors extends ValidationFieldMap = {}, +>(initial?: { + form?: TFormError + fields?: ValidateFieldsMap +}) => CreatedErrorMap< + TFormData, + InferredErrorMapItem +> + +export type ParseIssuesFn = ( + issues: ReadonlyArray, +) => ParsedStandardSchemaIssues + export type CreateOutputFn = ( value: TOutput, ) => ValidationOutput @@ -154,21 +170,21 @@ type GetValidatorError< ? GetRunnerError : never -type BroadValidators = Array<{ - run: StandardSchemaV1 | ((...args: any) => unknown) -}> - type GetValidatorsError = // Unlike unknown (omitted validators), an explicit any keeps basic errors. 0 extends 1 & TValidators ? ValidationIssue : TValidators extends ReadonlyArray - ? BroadValidators extends TValidators - ? ValidationIssue - : GetValidatorError + ? GetValidatorError : never export type GetFormErrorTypes = ScopedErrorTypes< GetValidatorsError, GetValidatorsError > + +// TODO get rid of this to complete migration +export type ToFormErrorTypes = + TScope extends ScopedErrorTypes + ? FormErrorTypes + : never diff --git a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts new file mode 100644 index 0000000000..f68e36d59e --- /dev/null +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -0,0 +1,915 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { formOptions } from '../../src/FormApi/formOptions.public' +import type { AnyFieldApi } from '../../src/FieldApi/FieldApi.public' +import type { FormApi, OnSubmitError } from '../../src/FormApi/FormApi.public' +import type { ServerFormState } from '../../src/FormApi/formServerState.public' +import type { StandardSchemaV1Issue } from '../../src/standardSchema.public' +// TODO finalize migration +import type { FormErrorTypes } from '../../src/validation.public' +import type { + CreateErrorMapFn, + ParseIssuesFn, + ParsedStandardSchemaIssues, + ValidationFailureItem, +} from '../../src/validation/state.public' + +describe('formOptions server state', () => { + it('accepts omitted, null, and initial server state without validators', () => { + const options = formOptions({ defaultValues: { name: '' } }) + expectTypeOf(options.serverState).toEqualTypeOf< + ServerFormState<{ name: string }, unknown> | null | undefined + >() + + formOptions({ defaultValues: { name: '' }, serverState: null }) + formOptions({ + defaultValues: { name: '' }, + serverState: { + values: undefined, + validationResults: [], + submissionAttempts: 0, + }, + }) + }) + + it('preserves server error types and validator output inference', () => { + const serverState = { + values: { name: 'Submitted' }, + validationResults: [ + { + validatorIndex: 0, + result: { message: 'Invalid', code: 'server' as const }, + output: null, + }, + ], + submissionAttempts: 1, + } + + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['server'], + run: () => ({ message: 'Invalid', code: 'server' as const }), + }, + { + triggers: [{ trigger: 'server', when: true }], + run: ({ createOutput, value }) => createOutput(value.name.length), + }, + { + triggers: ['change'], + run: () => ({ message: 'Invalid', code: 'client' as const }), + }, + ], + serverState, + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< + [undefined, number, undefined] + >() + }, + }) + + formOptions({ + ...options, + serverState: { + values: { name: '' }, + validationResults: [ + { + validatorIndex: 2, + // @ts-expect-error Client-only issues cannot occur in server state. + result: { message: 'Invalid', code: 'client' }, + output: null, + }, + ], + submissionAttempts: 1, + }, + }) + + formOptions({ + ...options, + serverState: { + // @ts-expect-error Server state must match the defaultValues shape. + values: { name: 123 }, + validationResults: [], + submissionAttempts: 1, + }, + }) + }) +}) + +describe('formOptions listener inference', () => { + it('types listener callbacks from defaultValues without validators', () => { + formOptions({ + defaultValues: { name: '', age: 0 }, + listeners: [ + { + triggers: [ + 'change', + 'blur', + 'submit', + 'mount', + 'reset', + { trigger: 'change' }, + { trigger: 'blur', when: false }, + { + trigger: 'change', + when: ({ value, formApi, triggerFieldApi }) => { + expectTypeOf(value).toEqualTypeOf<{ + name: string + age: number + }>() + expectTypeOf(formApi.state.values).toEqualTypeOf() + expectTypeOf(triggerFieldApi).toEqualTypeOf< + AnyFieldApi | undefined + >() + return value.age > 0 + }, + }, + ], + triggerDebounceMs: ({ value, formApi, triggerFieldApi }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + expectTypeOf(formApi.state.values).toEqualTypeOf() + expectTypeOf(triggerFieldApi).toEqualTypeOf< + AnyFieldApi | undefined + >() + return value.age > 0 ? 200 : 0 + }, + run: async ({ value, formApi, triggerFieldApi }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + expectTypeOf(formApi.state.values).toEqualTypeOf() + expectTypeOf(formApi.state.errors).toEqualTypeOf() + expectTypeOf(triggerFieldApi).toEqualTypeOf< + AnyFieldApi | undefined + >() + }, + }, + { triggers: [], triggerDebounceMs: 200, run: () => undefined }, + ], + }) + }) + + it('preserves validator errors and submission outputs with listeners', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + run: () => ({ + form: { message: 'Invalid form', code: 'form' as const }, + fields: { + name: { message: 'Invalid name', code: 'field' as const }, + }, + }), + }, + { + run: ({ value, createOutput }) => createOutput(value.name.length), + }, + ], + listeners: [ + { + triggers: ['change'], + run: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(formApi.state.errors).toEqualTypeOf< + Array<{ message: string; code: 'form' }> + >() + expectTypeOf(formApi).toEqualTypeOf< + FormApi< + typeof value, + FormErrorTypes< + { message: string; code: 'form' }, + { message: string; code: 'field' } + > + > + >() + }, + }, + ], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf<[undefined, number]>() + }, + }) + }) +}) + +describe('formOptions validator inference', () => { + it('exposes separate error helpers across client, mount, and server contexts', () => { + formOptions({ + defaultValues: { users: [{ name: '' }] }, + validators: [ + { + triggers: ['change', 'blur', 'mount', 'server'], + run: ({ createErrorMap, parseIssues }) => { + type Data = { users: Array<{ name: string }> } + expectTypeOf(createErrorMap).toEqualTypeOf>() + expectTypeOf(parseIssues).toEqualTypeOf>() + + const errors = createErrorMap({ form: ['Please check the form'] }) + errors.fields['users[0].name'] = 'Required' + expectTypeOf(errors.form).toEqualTypeOf>() + // @ts-expect-error Error maps retain the inferred error item type. + errors.form.push({ message: 'Invalid' }) + // @ts-expect-error Error map fields must belong to the form values. + errors.fields.missing = 'Invalid' + + const issues: ReadonlyArray = [ + { message: 'Required', path: ['users', 0, 'name'] }, + ] + const parsed = parseIssues(issues) + expectTypeOf(parsed).toEqualTypeOf< + ParsedStandardSchemaIssues + >() + parsed.form.push({ message: 'Invalid form' }) + // @ts-expect-error Parsed error maps retain the form's field paths. + parsed.fields.missing = { message: 'Invalid' } + // @ts-expect-error Standard Schema issues belong to parseIssues. + createErrorMap(issues) + + return parsed + }, + }, + ], + }) + }) + + it('infers submit-only callbacks when triggers are omitted or empty', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: ({ event, value, formApi }) => { + expectTypeOf(event).toEqualTypeOf<'submit'>() + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(formApi.state.values.name).toEqualTypeOf() + }, + }, + { + triggers: [], + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'submit'>() + }, + }, + ], + }) + }) + + it('keeps trigger and submit settings local to each validator', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'submit'>() + }, + }, + { + triggers: ['server', 'change'], + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'server' | 'change' | 'submit'>() + }, + }, + { + triggers: ['blur'], + skipOnSubmit: true, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'blur'>() + }, + }, + ], + }) + }) + + it('infers form values and event-specific capabilities in mixed validators', () => { + formOptions({ + defaultValues: { name: '', age: 0 }, + validators: [ + { + triggers: [ + { + trigger: 'change', + when: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf<{ + name: string + age: number + }>() + expectTypeOf(formApi.state.values).toEqualTypeOf<{ + name: string + age: number + }>() + return value.age > 0 + }, + }, + 'server', + ], + skipOnSubmit: false, + run: (ctx) => { + expectTypeOf(ctx.event).toEqualTypeOf< + 'change' | 'server' | 'submit' + >() + expectTypeOf(ctx.value).toEqualTypeOf<{ + name: string + age: number + }>() + // @ts-expect-error Client-only properties require narrowing the event. + void ctx.formApi + if (ctx.event === 'server') { + // @ts-expect-error Server validators have no client form API. + void ctx.formApi + // @ts-expect-error Server validators have no client abort signal. + void ctx.signal + } else { + expectTypeOf( + ctx.formApi.state.values.name, + ).toEqualTypeOf() + expectTypeOf(ctx.signal).toEqualTypeOf() + } + }, + }, + { + triggers: ['mount'], + skipOnSubmit: true, + run: (ctx) => { + expectTypeOf(ctx.event).toEqualTypeOf<'mount'>() + expectTypeOf(ctx.value.age).toEqualTypeOf() + }, + }, + ], + }) + }) + + it('keeps submit possible when skipOnSubmit is a boolean', () => { + const settings: { skipOnSubmit: boolean } = { skipOnSubmit: false } + + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['server', 'change'], + skipOnSubmit: settings.skipOnSubmit, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'server' | 'change' | 'submit'>() + }, + }, + { + skipOnSubmit: settings.skipOnSubmit, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'submit'>() + }, + }, + { + triggers: [], + skipOnSubmit: settings.skipOnSubmit, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'submit'>() + }, + }, + ], + }) + }) + + it('infers skip predicates from form values and keeps submit in callback contexts', () => { + // This is likely overkill, but just in case someone tries this weird combo. + // If it causes problems in future changes, feel free to remove. + const settings: { skipOnSubmit: true | (() => boolean) } = { + skipOnSubmit: true, + } + + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change', 'server'], + skipOnSubmit: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(formApi.state.values).toEqualTypeOf<{ name: string }>() + return value.name === '' + }, + run: (ctx) => { + expectTypeOf(ctx.event).toEqualTypeOf< + 'change' | 'server' | 'submit' + >() + if (ctx.event !== 'server') { + expectTypeOf( + ctx.formApi.state.values.name, + ).toEqualTypeOf() + } else { + // @ts-expect-error Server validators have no client form API. + void ctx.formApi + } + }, + }, + { + triggers: ['blur'], + skipOnSubmit: settings.skipOnSubmit, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'blur' | 'submit'>() + }, + }, + ], + }) + }) + + it('rejects asynchronous and non-boolean skip predicates', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + // @ts-expect-error Predicates must return a boolean. + skipOnSubmit: () => 'yes', + run: () => undefined, + }, + { + // @ts-expect-error Predicates cannot return a promise. + skipOnSubmit: async () => true, + run: () => undefined, + }, + ], + }) + }) + + it('accepts schema validators with conditional, omitted, or empty triggers', () => { + const schema = z.object({ name: z.string() }) + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: [ + { + trigger: 'change', + when: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + return value.name !== '' + }, + }, + ], + run: schema, + }, + { run: schema }, + { triggers: [], run: schema }, + ], + }) + }) + + it('infers all configured events from string trigger entries', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change', 'blur', 'mount', 'server'], + skipOnSubmit: true, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf< + 'change' | 'blur' | 'mount' | 'server' + >() + }, + }, + ], + }) + }) + + it('infers each event from a single object trigger entry', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: [ + { + trigger: 'change', + when: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(formApi.state.values).toEqualTypeOf<{ + name: string + }>() + return value.name !== '' + }, + }, + ], + skipOnSubmit: true, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'change'>() + }, + }, + { + triggers: [{ trigger: 'blur', when: true }], + skipOnSubmit: true, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'blur'>() + }, + }, + { + triggers: [{ trigger: 'server', when: true }], + skipOnSubmit: true, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'server'>() + }, + }, + { + triggers: [{ trigger: 'mount', when: false }], + skipOnSubmit: true, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf<'mount'>() + }, + }, + ], + }) + }) + + it('rejects when callbacks for server and mount triggers', () => { + formOptions({ + defaultValues: { name: '' }, + // Perhaps sometimes in the future, we can make it so that the `when` simply has + // different context based on the other trigger? But that seems like a lot of pain + // for a minor case. + + // Point is, these tests are important for the present, but feel free to adjust in the future. + validators: [ + { + // @ts-expect-error Server conditions must be booleans. + triggers: [{ trigger: 'server', when: () => true }], + run: () => undefined, + }, + { + // @ts-expect-error Mount conditions must be booleans. + triggers: [{ trigger: 'mount', when: () => false }], + skipOnSubmit: true, + run: () => undefined, + }, + { + // @ts-expect-error A client trigger does not allow server predicates. + triggers: ['change', { trigger: 'server', when: () => true }], + skipOnSubmit: true, + run: () => undefined, + }, + { + // @ts-expect-error A client trigger does not allow mount predicates. + triggers: ['blur', { trigger: 'mount', when: () => false }], + run: () => undefined, + }, + ], + }) + }) + + it('infers events and predicate values from mixed string and object entries', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: [ + 'server', + { trigger: 'mount', when: true }, + 'change', + { + trigger: 'blur', + when: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(formApi.state.values).toEqualTypeOf<{ + name: string + }>() + return value.name !== '' + }, + }, + ], + skipOnSubmit: true, + run: ({ event }) => { + expectTypeOf(event).toEqualTypeOf< + 'change' | 'blur' | 'mount' | 'server' + >() + }, + }, + ], + }) + }) + + it('rejects unsupported trigger names and invalid conditions', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + // @ts-expect-error Submit is controlled by skipOnSubmit. + triggers: ['submit'], + run: () => undefined, + }, + { + // @ts-expect-error Object triggers cannot explicitly select submit. + triggers: [{ trigger: 'submit', when: () => true }], + run: () => undefined, + }, + { + // @ts-expect-error Unknown trigger names are invalid. + triggers: ['unknown'], + run: () => undefined, + }, + { + // @ts-expect-error Valid triggers do not make an invalid trigger acceptable. + triggers: ['change', { trigger: 'submit', when: () => true }], + run: () => undefined, + }, + { + // @ts-expect-error Predicates must return a boolean. + triggers: [{ trigger: 'change', when: () => 'yes' }], + run: () => undefined, + }, + { + // @ts-expect-error Object triggers require a when condition. + triggers: [{ trigger: 'change' }], + run: () => undefined, + }, + ], + }) + }) +}) + +describe('formOptions validator returns', () => { + it('accepts direct errors and field error maps, including asynchronous results', () => { + const issue = { message: 'Required', code: 'required' as const } + const errors = ['Invalid', issue] as const + + formOptions({ + defaultValues: { users: [{ name: '' }] }, + validators: [ + { run: () => 'Required' }, + { run: () => issue }, + { run: () => errors }, + { + run: () => ({ + form: errors, + fields: { 'users[0].name': issue }, + }), + }, + { run: async () => errors }, + { + run: async () => ({ + form: issue, + fields: { 'users[0].name': errors }, + }), + }, + { + run: async ({ createErrorMap }) => + createErrorMap({ fields: { 'users[0].name': 'Required' } }), + }, + ], + }) + }) + + it('accepts successful results without output, including asynchronous results', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { run: () => {} }, + { run: () => undefined }, + { run: () => null }, + // explicit false allows for checks like `condition && 'error'` + { run: () => false }, + { run: async () => {} }, + { run: async () => undefined }, + { run: async () => null }, + { run: async () => false as const }, + ], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< + [ + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ] + >() + }, + }) + }) + + it('rejects invalid errors in synchronous and asynchronous results', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + // @ts-expect-error Numbers are not validation errors or successful results. + run: () => 42, + }, + { + // @ts-expect-error Only false is a successful boolean result. + run: () => true, + }, + { + // @ts-expect-error Issue objects require a message. + run: () => ({ code: 'invalid' }), + }, + { + // @ts-expect-error Every item must be a valid error. + run: () => ['Invalid', 42], + }, + { + // @ts-expect-error Form error maps must contain valid errors. + run: () => ({ form: 42, fields: {} }), + }, + { + // @ts-expect-error Field error maps must contain valid errors. + run: () => ({ fields: { name: 42 } }), + }, + { + // @ts-expect-error Field issue objects also require a message. + run: () => ({ fields: { name: { code: 'invalid' } } }), + }, + { + // @ts-expect-error Promises must also resolve to valid results. + run: async () => 42, + }, + { + // @ts-expect-error Asynchronous issue objects also require a message. + run: async () => ({ code: 'invalid' }), + }, + { + // @ts-expect-error Asynchronous field maps must contain valid errors. + run: async () => ({ fields: { name: true } }), + }, + ], + }) + }) +}) + +describe('formOptions onSubmit inference', () => { + it('exposes separate submission helpers and accepts returned schema issues', () => { + formOptions({ + defaultValues: { name: '' }, + onSubmit: ({ createErrorMap, parseIssues }) => { + const errors = createErrorMap({ fields: { name: 'Required' } }) + expectTypeOf(errors.form).toEqualTypeOf>() + errors.form.push('Please check the form') + // @ts-expect-error Submission errors retain the form's field paths. + errors.fields.missing = 'Invalid' + + const issues: ReadonlyArray = [ + { message: 'Required', path: ['name'] }, + ] + const parsed = parseIssues(issues) + expectTypeOf(parsed.form).toEqualTypeOf>() + expectTypeOf(parsed).toExtend< + OnSubmitError> + >() + // @ts-expect-error Parsed submission errors retain the form's field paths. + parsed.fields.missing = { message: 'Invalid' } + // @ts-expect-error Standard Schema issues belong to parseIssues. + createErrorMap(issues) + // @ts-expect-error Partial error maps belong to createErrorMap. + parseIssues({ form: 'Invalid' }) + + return parsed + }, + }) + }) + + it('accepts error maps returned from asynchronous submission handlers', () => { + formOptions({ + defaultValues: { name: '' }, + onSubmit: async ({ createErrorMap }) => { + return createErrorMap({ fields: { name: 'Required' } }) + }, + }) + }) + + it('infers submitted values and the form API from defaultValues', () => { + formOptions({ + defaultValues: { name: '', age: 0 }, + validators: [{ run: () => undefined }], + onSubmit: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + expectTypeOf(formApi.state.values).toEqualTypeOf<{ + name: string + age: number + }>() + }, + }) + }) + + it('infers empty submission outputs when validators are omitted or empty', () => { + formOptions({ + defaultValues: { name: '' }, + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf<[]>() + }, + }) + + formOptions({ + defaultValues: { name: '' }, + validators: [], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf<[]>() + }, + }) + }) + + it('infers submission outputs from asynchronous validator callbacks', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: async ({ value, createOutput }) => createOutput(value.name), + }, + ], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf<[string]>() + }, + }) + }) + + it('infers the same transformed submission output from schema callbacks and schemas', () => { + const schema = z + .object({ name: z.string() }) + .transform(({ name }) => name.length) + + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + run: ({ createOutput, parseIssues, value }) => { + const result = schema.safeParse(value) + if (!result.success) { + return parseIssues(result.error.issues) + } + return createOutput(result.data) + }, + }, + { triggers: ['change'], run: schema }, + ], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf<[number, number]>() + }, + }) + }) + + it('makes predicate-controlled submission outputs optional', () => { + const settings: { skipOnSubmit: true | (() => boolean) } = { + skipOnSubmit: true, + } + const schema = z + .object({ name: z.string() }) + .transform(({ name }) => name.length) + + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + skipOnSubmit: ({ value }) => value.name === '', + run: ({ createOutput, value }) => createOutput(value.name), + }, + { skipOnSubmit: ({ value }) => value.name === '', run: schema }, + { skipOnSubmit: settings.skipOnSubmit, run: schema }, + ], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< + [string | undefined, number | undefined, number | undefined] + >() + }, + }) + }) + + it('infers submission outputs from boolean and literal skip settings', () => { + const settings: { skipOnSubmit: boolean } = { skipOnSubmit: false } + const schema = z + .object({ name: z.string() }) + .transform(({ name }) => name.length) + + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + skipOnSubmit: settings.skipOnSubmit, + run: ({ createOutput, value }) => createOutput(value.name), + }, + { skipOnSubmit: settings.skipOnSubmit, run: schema }, + { + skipOnSubmit: true, + triggers: ['change'], + run: ({ createOutput, value }) => createOutput(value.name), + }, + { skipOnSubmit: true, run: schema }, + { + skipOnSubmit: false, + run: ({ createOutput, value }) => createOutput(value.name), + }, + { skipOnSubmit: false, run: schema }, + { run: ({ createOutput, value }) => createOutput(value.name) }, + { run: schema }, + ], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< + [ + string | undefined, + number | undefined, + undefined, + undefined, + string, + number, + string, + number, + ] + >() + }, + }) + }) +}) diff --git a/packages/form-core/tests/validation/createErrorMap.lib.spec.ts b/packages/form-core/tests/validation/createErrorMap.lib.spec.ts index d89278e1ff..3797a263a2 100644 --- a/packages/form-core/tests/validation/createErrorMap.lib.spec.ts +++ b/packages/form-core/tests/validation/createErrorMap.lib.spec.ts @@ -1,6 +1,5 @@ import { assert, describe, expect, it } from 'vitest' -import { createErrorMapFactory } from '../../src/validation/createErrorMap.lib' -import type { StandardSchemaV1Issue } from '../../src/standardSchema.public' +import { createErrorMap } from '../../src/validation/createErrorMap.lib' describe('createErrorMap', () => { it.each([ @@ -10,16 +9,12 @@ describe('createErrorMap', () => { expected: [{ message: 'Invalid', code: 'custom' }], }, ])('normalizes a single form error: $form', ({ form, expected }) => { - const createErrorMap = createErrorMapFactory({ name: '' }) - const map = createErrorMap({ form }) expect(map).toEqual({ form: expected, fields: {} }) }) it('accepts single and multiple field errors without a form error', () => { - const createErrorMap = createErrorMapFactory({ name: '', age: 0 }) - const map = createErrorMap({ fields: { name: 'Required', age: ['Too young', 'Must be an adult'] }, }) @@ -31,7 +26,6 @@ describe('createErrorMap', () => { }) it('allows editing errors without changing the supplied readonly containers', () => { - const createErrorMap = createErrorMapFactory({ name: '', age: 0 }) const issue = { message: 'Invalid', code: 'custom' } // While `expect(old).not.toBe(new)` reads better, // we need to verify that @@ -68,7 +62,6 @@ describe('createErrorMap', () => { ])( 'keeps separately created maps independent with $scenario', ({ initial }) => { - const createErrorMap = createErrorMapFactory({ name: '' }) const first = createErrorMap(initial) const second = createErrorMap(initial) @@ -82,65 +75,4 @@ describe('createErrorMap', () => { expect(second).toEqual({ form: [], fields: {} }) }, ) - - it('keeps maps created from an empty schema issue list independent', () => { - const createErrorMap = createErrorMapFactory({ name: '' }) - const issues: Array = [] - const first = createErrorMap(issues) - const second = createErrorMap(issues) - - first.form.push({ message: 'Invalid form' }) - first.fields.name = { message: 'Required' } - - expect(first).toEqual({ - form: [{ message: 'Invalid form' }], - fields: { name: { message: 'Required' } }, - }) - expect(second).toEqual({ form: [], fields: {} }) - expect(issues).toEqual([]) - }) - - it('keeps all schema issues in the form summary and groups nested field issues', () => { - const createErrorMap = createErrorMapFactory({ users: [{ name: '' }] }) - const rootIssue: StandardSchemaV1Issue = { message: 'Invalid form' } - const required: StandardSchemaV1Issue = { - message: 'Required', - path: ['users', { key: '0' }, 'name'], - } - const tooShort: StandardSchemaV1Issue = { - message: 'Too short', - path: ['users', 0, 'name'], - } - - const map = createErrorMap([rootIssue, required, tooShort]) - - expect(map).toEqual({ - form: [rootIssue, required, tooShort], - fields: { 'users[0].name': [required, tooShort] }, - }) - }) - - it('allows editing schema field errors independently of the summary and input', () => { - const createErrorMap = createErrorMapFactory({ name: '' }) - const issue: StandardSchemaV1Issue = { - message: 'Required', - path: ['name'], - } - const issues = Object.freeze([issue]) - const map = createErrorMap(issues) - - const nameErrors = map.fields.name - assert(Array.isArray(nameErrors)) - nameErrors.push({ message: 'Too short' }) - - expect(map.form).toEqual([issue]) - - map.form.push({ message: 'Check the form' }) - - expect(map).toEqual({ - form: [issue, { message: 'Check the form' }], - fields: { name: [issue, { message: 'Too short' }] }, - }) - expect(issues).toEqual([issue]) - }) }) diff --git a/packages/form-core/tests/validation/state.public.test-d.ts b/packages/form-core/tests/validation/state.public.test-d.ts new file mode 100644 index 0000000000..ac9c02a6d8 --- /dev/null +++ b/packages/form-core/tests/validation/state.public.test-d.ts @@ -0,0 +1,85 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { StandardSchemaV1Issue } from '../../src/standardSchema.public' +import type { + CreateErrorMapFn, + ValidationFailureItem, +} from '../../src/validation/state.public' + +declare const createErrorMap: CreateErrorMapFn +declare const createNameErrorMap: CreateErrorMapFn<{ name: string }> + +describe('CreateErrorMapFn', () => { + it('keeps string and issue errors mutable', () => { + const strings = createErrorMap({ form: ['Required'] }) + const scalarString = createErrorMap({ form: 'Required' }) + const readonlyStrings = createErrorMap({ form: ['Required'] as const }) + const issues = createErrorMap({ form: [{ message: 'Required' }] }) + const scalarIssue = createErrorMap({ form: { message: 'Required' } }) + const readonlyIssues = createErrorMap({ + form: [{ message: 'Required' }] as const, + }) + + strings.form.push('Too short') + scalarString.form.push('Too short') + readonlyStrings.form.push('Too short') + issues.form.push({ message: 'Too short' }) + scalarIssue.form.push({ message: 'Too short' }) + readonlyIssues.form.push({ message: 'Required' }) + strings.fields.name = 'Required' + strings.fields.age = ['Too young', 'Must be an adult'] + issues.fields.name = { message: 'Required' } + issues.fields.age = [{ message: 'Too young' }] + }) + + it('accepts strings and issues when no error item type can be inferred', () => { + const omitted = createErrorMap() + const empty = createErrorMap({ form: [] }) + const emptyFields = createErrorMap({ fields: { name: [] } }) + + expectTypeOf(omitted.form).toEqualTypeOf>() + expectTypeOf(empty.form).toEqualTypeOf>() + expectTypeOf(emptyFields.form).toEqualTypeOf>() + empty.form.push('Required') + omitted.form.push({ message: 'Invalid' }) + emptyFields.fields.name = { message: 'Invalid' } + }) + + it('retains custom issue types inferred from form or field errors', () => { + const issue = { message: 'Invalid name', code: 'name' as const } + const fromForm = createErrorMap({ form: [issue] }) + const fromFields = createErrorMap({ + form: [], + fields: { name: [issue], age: issue }, + }) + + expectTypeOf(fromForm.form).toEqualTypeOf>() + expectTypeOf(fromFields.form).toEqualTypeOf>() + fromForm.fields.name = [issue] + fromFields.form.push(issue) + }) + + it('rejects invalid errors and field paths', () => { + // @ts-expect-error Numbers are not validation errors. + createErrorMap({ form: 42 }) + // @ts-expect-error Issue objects require a message. + createErrorMap({ form: { code: 'invalid' } }) + // @ts-expect-error Initial field paths must belong to the scoped values. + createNameErrorMap({ fields: { missing: 'Invalid' } }) + // @ts-expect-error Known paths must not hide an invalid path in the same map. + createNameErrorMap({ fields: { name: 'Required', missing: 'Invalid' } }) + + const map = createNameErrorMap() + map.fields.name = 'Required' + // @ts-expect-error Mutation also checks field paths. + map.fields.missing = 'Invalid' + }) + + it('rejects schema issue arrays as initial maps', () => { + const issues: ReadonlyArray = [] + + // @ts-expect-error Schema issue arrays must be converted with parseIssues. + createErrorMap(issues) + // @ts-expect-error Empty arrays are not partial error maps either. + createErrorMap([]) + }) +}) From 3c0a45e25c6d1917c47652f235bb23deacef0666 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:47:05 +0200 Subject: [PATCH 06/43] refactor: rename submit error symbol --- packages/form-core/src/FormApi/FormApi.public.ts | 4 ++-- packages/form-core/src/FormApi/handleSubmit.lib.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index fbc63e1b2f..5567228ead 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -16,7 +16,7 @@ import type { FormListeners } from '../listeners.public' import type { FormApiArrayMethods } from './FormApiArrayMethods.types.public' import type { FormApiFieldMethods } from './FormApiFieldMethods.types.public' import type { ServerFormState } from '../ssr.public' -import type { SUBMIT_ERROR } from './handleSubmit.lib' +import type { submitErrorSymbol } from './handleSubmit.lib' /** * A validation error recognized as an `onSubmit` failure. @@ -44,7 +44,7 @@ import type { SUBMIT_ERROR } from './handleSubmit.lib' export type OnSubmitError< TFormValidationError extends FormValidationError, > = { - readonly [SUBMIT_ERROR]: TFormValidationError + readonly [submitErrorSymbol]: TFormValidationError } /** diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index 3fed6d4657..ad24cab6c1 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -19,12 +19,14 @@ import type { OnSubmitError, } from './FormApi.public' -export const SUBMIT_ERROR = Symbol('SUBMIT_ERROR') +export const submitErrorSymbol = Symbol('Submit error') function isSubmitError( value: unknown, ): value is OnSubmitError> { - return typeof value === 'object' && value !== null && SUBMIT_ERROR in value + return ( + typeof value === 'object' && value !== null && submitErrorSymbol in value + ) } const createValidationError: CreateValidationErrorFn = < @@ -38,7 +40,7 @@ const createValidationError: CreateValidationErrorFn = < function createSubmitError>( error: TError, ): OnSubmitError { - return { [SUBMIT_ERROR]: error } + return { [submitErrorSymbol]: error } } function createSubmitErrorMapFactory( @@ -49,7 +51,7 @@ function createSubmitErrorMapFactory( return ((initial) => { const map = createErrorMap(initial as never) // Read the map itself so edits made after creation are included. - Object.defineProperty(map, SUBMIT_ERROR, { value: map }) + Object.defineProperty(map, submitErrorSymbol, { value: map }) return map }) as CreateSubmitErrorMapFn } @@ -184,7 +186,7 @@ export async function runSubmissionProcess( // Store onSubmit errors separately from installed validator instances. if (isSubmitError(maybeError)) { - const error = maybeError[SUBMIT_ERROR] + const error = maybeError[submitErrorSymbol] form._processSubmitValidationResult(error, 'submit') submissionData.submitError = error } else { From 03b6f86c5b97d6621e1e46c46fbf2480cef22a3e Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:23:48 +0200 Subject: [PATCH 07/43] feat: add formOptions interface --- .../src/FormApi/formOptions.public.ts | 81 +- .../form-core/src/standardSchema.public.ts | 3 + packages/form-core/src/utils.public.ts | 26 +- .../FormApi/formOptions.public.test-d.ts | 715 +++++++++++++++++- 4 files changed, 806 insertions(+), 19 deletions(-) diff --git a/packages/form-core/src/FormApi/formOptions.public.ts b/packages/form-core/src/FormApi/formOptions.public.ts index 39fe4a9209..7e9c7613fb 100644 --- a/packages/form-core/src/FormApi/formOptions.public.ts +++ b/packages/form-core/src/FormApi/formOptions.public.ts @@ -10,6 +10,13 @@ import type { GetValidatorOutputs, } from './formSubmit.public' import type { FormOptionsValidators } from '../validation/formValidators.public' +import type { + StandardSchemaInput, + StandardSchemaV1, +} from '../standardSchema.public' +import type { Editable, InferUnion } from '../utils.public' + +declare const formOptionsComponentsSymbol: unique symbol type TryGetValidatorOutputs = TValidators extends ReadonlyArray @@ -20,7 +27,9 @@ export interface FormOptions< TFormData, TValidatorConfig extends unknown[], TValidators, + out TComponents, > { + readonly [formOptionsComponentsSymbol]?: TComponents defaultValues: TFormData formId?: string errorVisibility?: ErrorVisibility> @@ -31,7 +40,7 @@ export interface FormOptions< NoInfer> > serverState?: NoInfer> | null - // TODO FormErrorTypes + // TODO infer return submit for errors onSubmit?: FormOptionsOnSubmitFn< NoInfer, NoInfer>, @@ -39,12 +48,72 @@ export interface FormOptions< > } -export type FormOptionsFn = < - TFormData, +export type FormOptionsStrictSchemaFn = < + TSchema extends StandardSchemaV1, + const TValidatorConfig extends unknown[], + TValidators, +>( + schema: TSchema, + options: FormOptions< + NoInfer>, + TValidatorConfig, + TValidators, + unknown + >, +) => FormOptions< + NoInfer>, + TValidatorConfig, + TValidators, + TComponents +> + +type LooseSchemaFormOptions< + TSchemaInput, + TFormData extends Editable, + TValidatorConfig extends unknown[], + TValidators, +> = Omit< + FormOptions< + InferUnion, + TValidatorConfig, + TValidators, + unknown + >, + 'defaultValues' +> & { + defaultValues: TFormData +} + +export type FormOptionsLooseSchemaFn = < + TSchema extends StandardSchemaV1, + const TFormData extends Editable>, const TValidatorConfig extends unknown[], TValidators, >( - options: FormOptions, -) => FormOptions + schema: TSchema, + options: LooseSchemaFormOptions< + StandardSchemaInput, + TFormData, + TValidatorConfig, + TValidators + >, +) => FormOptions< + InferUnion>, + TValidatorConfig, + TValidators, + TComponents +> + +export interface FormOptionsApi { + ( + options: FormOptions, + ): FormOptions + + strictSchema: FormOptionsStrictSchemaFn + looseSchema: FormOptionsLooseSchemaFn +} + +export const formOptions = ((opts) => opts) as FormOptionsApi -export const formOptions: FormOptionsFn = (opts) => opts +formOptions.strictSchema = (_schema, opts) => opts +formOptions.looseSchema = (_schema, opts) => opts as never diff --git a/packages/form-core/src/standardSchema.public.ts b/packages/form-core/src/standardSchema.public.ts index 37354fb895..f662e57194 100644 --- a/packages/form-core/src/standardSchema.public.ts +++ b/packages/form-core/src/standardSchema.public.ts @@ -101,3 +101,6 @@ interface StandardSchemaV1Types { */ readonly output: TOutput } + +export type StandardSchemaInput> = + TSchema extends StandardSchemaV1 ? TInput : never diff --git a/packages/form-core/src/utils.public.ts b/packages/form-core/src/utils.public.ts index 0955abaebf..4782f36873 100644 --- a/packages/form-core/src/utils.public.ts +++ b/packages/form-core/src/utils.public.ts @@ -9,7 +9,7 @@ export type BuiltInType = Primitive | Date | RegExp | Function export type Editable = T extends BuiltInType ? T | null | undefined : T extends ReadonlyArray - ? Array> | null | undefined + ? ReadonlyArray> | null | undefined : T extends object ? EditableObject | null | undefined : T | null | undefined @@ -18,19 +18,21 @@ type EditableObject = { [K in keyof T]?: Editable } -export type InferUnion = TBase extends BuiltInType - ? TBase | TIncoming - : TIncoming extends BuiltInType +export type InferUnion = [TBase] extends [never] + ? TIncoming + : TBase extends BuiltInType ? TBase | TIncoming - : TBase extends ReadonlyArray - ? TIncoming extends ReadonlyArray - ? Array> - : TBase | TIncoming - : TBase extends object - ? TIncoming extends object - ? InferUnionObject + : TIncoming extends BuiltInType + ? TBase | TIncoming + : TBase extends ReadonlyArray + ? TIncoming extends ReadonlyArray + ? Array> + : TBase | TIncoming + : TBase extends object + ? TIncoming extends object + ? InferUnionObject + : TBase | TIncoming : TBase | TIncoming - : TBase | TIncoming type InferUnionObject< in out TBase extends object, diff --git a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts index f68e36d59e..a33525b894 100644 --- a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -3,6 +3,10 @@ import { z } from 'zod' import { formOptions } from '../../src/FormApi/formOptions.public' import type { AnyFieldApi } from '../../src/FieldApi/FieldApi.public' import type { FormApi, OnSubmitError } from '../../src/FormApi/FormApi.public' +import type { + FormOptions, + FormOptionsApi, +} from '../../src/FormApi/formOptions.public' import type { ServerFormState } from '../../src/FormApi/formServerState.public' import type { StandardSchemaV1Issue } from '../../src/standardSchema.public' // TODO finalize migration @@ -14,6 +18,716 @@ import type { ValidationFailureItem, } from '../../src/validation/state.public' +describe('formOptions.strictSchema', () => { + it('uses the complete schema input without registering a validator', () => { + const schema = z.object({ + status: z.enum(['draft', 'published']), + age: z.coerce.number().optional(), + }) + type SchemaInput = z.input + + const options = formOptions.strictSchema(schema, { + defaultValues: { status: 'draft' }, + onSubmit: ({ value, formApi, validatorOutputs }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + expectTypeOf(validatorOutputs).toEqualTypeOf<[]>() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('rejects incomplete defaults, invalid input, and parsed output', () => { + const schema = z.object({ + name: z.string(), + age: z.coerce.number(), + }) + + formOptions.strictSchema(schema, { + // @ts-expect-error Strict defaults must include required schema fields. + defaultValues: { age: '' }, + }) + formOptions.strictSchema(schema, { + defaultValues: { + // @ts-expect-error A non-nullable schema input cannot start as null. + name: null, + age: '', + }, + }) + formOptions.strictSchema(schema, { + defaultValues: { + // @ts-expect-error A required schema input cannot start as undefined. + name: undefined, + age: '', + }, + }) + formOptions.strictSchema(schema, { + defaultValues: { + name: '', + // @ts-expect-error Defaults use schema input, not transformed output. + age: 0, + }, + }) + formOptions.strictSchema(schema, { + defaultValues: { + name: '', + age: '', + // @ts-expect-error Strict defaults cannot add fields absent from the schema. + extra: true, + }, + }) + formOptions.strictSchema(schema, { + defaultValues: { + name: '', + age: '', + }, + }) + }) + + it('types callback-only validators and predicates from the schema input', () => { + const schema = z.object({ + status: z.enum(['draft', 'published']), + note: z.string().optional(), + }) + type SchemaInput = z.input + + formOptions.strictSchema(schema, { + defaultValues: { status: 'draft' }, + validators: [ + { + triggers: [ + { + trigger: 'change', + when: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + return value.status === 'published' + }, + }, + ], + skipOnSubmit: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + return value.status === 'draft' + }, + run: ({ value, formApi, event, createErrorMap, parseIssues }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + expectTypeOf(event).toEqualTypeOf<'change' | 'submit'>() + expectTypeOf(createErrorMap).toEqualTypeOf< + CreateErrorMapFn + >() + expectTypeOf(parseIssues).toEqualTypeOf< + ParseIssuesFn + >() + }, + }, + { + triggers: ['mount', 'server'], + skipOnSubmit: true, + run: ({ value, event }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(event).toEqualTypeOf<'mount' | 'server'>() + }, + }, + ], + }) + }) + + it('keeps schema input separate from mixed validator outputs', () => { + const schema = z.object({ + age: z.coerce.number(), + name: z.string().optional(), + }) + type SchemaInput = z.input + type SchemaOutput = z.output + + formOptions.strictSchema(schema, { + defaultValues: { age: '' }, + validators: [ + { + run: async ({ value, createOutput }) => { + expectTypeOf(value).toEqualTypeOf() + return createOutput(value.age.length) + }, + }, + { run: schema }, + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + return { message: 'Check age', code: 'age' as const } + }, + }, + ], + onSubmit: ({ value, formApi, validatorOutputs }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + expectTypeOf(formApi.state.errors).toEqualTypeOf< + Array + >() + expectTypeOf(validatorOutputs).toEqualTypeOf< + [number, SchemaOutput, undefined] + >() + }, + }) + }) + + it('types listeners and error visibility from schema input without validators', () => { + const schema = z.object({ + status: z.enum(['draft', 'published']), + note: z.string().optional(), + }) + type SchemaInput = z.input + + formOptions.strictSchema(schema, { + defaultValues: { status: 'draft' }, + errorVisibility: ({ state }) => { + expectTypeOf(state.values).toEqualTypeOf() + return state.values.status === 'published' + }, + listeners: [ + { + triggers: [ + { + trigger: 'change', + when: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + return value.note !== undefined + }, + }, + ], + triggerDebounceMs: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + return value.status === 'draft' ? 0 : 200 + }, + run: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + }, + }, + ], + }) + }) + + it('types server state from schema input rather than defaults or parsed output', () => { + const schema = z.object({ + status: z.enum(['draft', 'published']), + age: z.coerce.number(), + note: z.string().optional(), + }) + type SchemaInput = z.input + + const options = formOptions.strictSchema(schema, { + defaultValues: { status: 'draft', age: '' }, + serverState: { + values: { status: 'published', age: '42', note: 'Submitted' }, + validationResults: [], + submissionAttempts: 1, + }, + }) + + expectTypeOf(options.serverState).toEqualTypeOf< + ServerFormState | null | undefined + >() + + formOptions.strictSchema(schema, { + ...options, + serverState: { + values: { + status: 'published', + // @ts-expect-error Server values must retain the untransformed input type. + age: 42, + }, + validationResults: [], + submissionAttempts: 1, + }, + }) + formOptions.strictSchema(schema, { + ...options, + serverState: { + // @ts-expect-error Required schema inputs must also be present in server values. + values: { status: 'published' }, + validationResults: [], + submissionAttempts: 1, + }, + }) + }) + + it('preserves every discriminated union branch when defaults select one', () => { + const schema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('person'), name: z.string() }), + z.object({ kind: z.literal('company'), companyName: z.string() }), + ]) + type SchemaInput = z.input + + const options = formOptions.strictSchema(schema, { + defaultValues: { kind: 'person', name: '' }, + validators: [ + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + if (value.kind === 'person') { + expectTypeOf(value.name).toEqualTypeOf() + // @ts-expect-error Company fields require the company branch. + void value.companyName + } else { + expectTypeOf(value.companyName).toEqualTypeOf() + // @ts-expect-error Person fields require the person branch. + void value.name + } + }, + }, + ], + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + + const invalidDefaults = { kind: 'company' as const, name: '' } + formOptions.strictSchema(schema, { + // @ts-expect-error Defaults must match a member of the union. + defaultValues: invalidDefaults, + }) + }) +}) + +describe('formOptions.looseSchema', () => { + it('merges omitted and nested defaults with the schema input', () => { + type Value = { + name: string | undefined + address: { + city: string | null + postcode: number | undefined + } + } + const schema = z.object({ + name: z.string(), + address: z.object({ city: z.string(), postcode: z.number() }), + }) + + const options = formOptions.looseSchema(schema, { + defaultValues: { address: { city: null } }, + onSubmit: ({ value, formApi, validatorOutputs }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + expectTypeOf(validatorOutputs).toEqualTypeOf<[]>() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('adds only the null and undefined states present in defaults', () => { + type Value = { + name: string | null + age: number | undefined + active: boolean + } + const schema = z.object({ + name: z.string(), + age: z.number(), + active: z.boolean(), + }) + + const options = formOptions.looseSchema(schema, { + defaultValues: { name: null, age: undefined, active: false }, + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('rejects incompatible values within the editable schema input', () => { + const schema = z.object({ + age: z.coerce.number(), + users: z.array(z.object({ name: z.string() })), + }) + + formOptions.looseSchema(schema, { + defaultValues: { + // @ts-expect-error Loose defaults still require a compatible input type. + age: false, + }, + }) + formOptions.looseSchema(schema, { + defaultValues: { + // @ts-expect-error Parsed output is not an editable schema input. + age: 0, + }, + }) + formOptions.looseSchema(schema, { + defaultValues: { + // @ts-expect-error An array field cannot start as a string. + users: '', + }, + }) + formOptions.looseSchema(schema, { + defaultValues: { + // @ts-expect-error Nested array items must also match the editable input. + users: [{ name: false }], + }, + }) + }) + + it('types callback-only validators and predicates from merged form values', () => { + type Value = { + name: string | undefined + age: string | null + address: { + city: string | null + postcode: number | undefined + } + } + const schema = z.object({ + name: z.string(), + age: z.coerce.number(), + address: z.object({ city: z.string(), postcode: z.number() }), + }) + + const options = formOptions.looseSchema(schema, { + defaultValues: { age: null, address: { city: null } }, + validators: [ + { + triggers: [ + { + trigger: 'blur', + when: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + return value.age !== null + }, + }, + ], + skipOnSubmit: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + return value.name === undefined + }, + run: ({ value, formApi, event, createErrorMap, parseIssues }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + expectTypeOf(event).toEqualTypeOf<'blur' | 'submit'>() + expectTypeOf(createErrorMap).toEqualTypeOf< + CreateErrorMapFn + >() + expectTypeOf(parseIssues).toEqualTypeOf>() + return createErrorMap({ + fields: { name: 'Required', 'address.postcode': 'Required' }, + }) + }, + }, + ], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf<[undefined]>() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('keeps editable form values separate from transformed and skipped outputs', () => { + type Value = { age: string | null; name: string | undefined } + const schema = z.object({ + age: z.coerce.number(), + name: z.string(), + }) + + type SchemaOutput = z.output + + formOptions.looseSchema(schema, { + defaultValues: { age: null }, + validators: [ + { + run: async ({ value, createOutput }) => { + expectTypeOf(value).toEqualTypeOf() + return createOutput(value.name ?? '') + }, + }, + { run: schema }, + { + skipOnSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + return value.age === null + }, + run: schema, + }, + { skipOnSubmit: true, run: schema }, + ], + onSubmit: ({ value, formApi, validatorOutputs }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + expectTypeOf(formApi.state.errors).toEqualTypeOf< + Array + >() + expectTypeOf(validatorOutputs).toEqualTypeOf< + [string, SchemaOutput, SchemaOutput | undefined, undefined] + >() + }, + }) + }) + + it('types listeners and error visibility from merged values without validators', () => { + type UnionValue = { name: string | undefined; age: number | null } + const schema = z.object({ name: z.string(), age: z.number() }) + + formOptions.looseSchema(schema, { + defaultValues: { age: null }, + errorVisibility: ({ state }) => { + expectTypeOf(state.values).toEqualTypeOf() + return state.values.age !== null + }, + listeners: [ + { + triggers: [ + { + trigger: 'change', + when: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + return value.name !== undefined + }, + }, + ], + triggerDebounceMs: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + return value.age === null ? 0 : 200 + }, + run: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + }, + }, + ], + }) + }) + + it('types server state from merged values without widening the form type', () => { + type UnionValue = { + status: 'draft' | 'published' + age: string | null + name: string | undefined + } + const schema = z.object({ + status: z.enum(['draft', 'published']), + age: z.coerce.number(), + name: z.string(), + }) + + const options = formOptions.looseSchema(schema, { + defaultValues: { status: 'draft', age: null }, + serverState: { + values: { status: 'published', age: '42', name: 'Submitted' }, + validationResults: [], + submissionAttempts: 1, + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + expectTypeOf(options.serverState).toEqualTypeOf< + ServerFormState | null | undefined + >() + + formOptions.looseSchema(schema, { + defaultValues: { status: 'draft', age: null }, + serverState: { + values: { status: 'draft', age: null, name: undefined }, + validationResults: [], + submissionAttempts: 1, + }, + }) + formOptions.looseSchema(schema, { + defaultValues: { status: 'draft', age: null }, + serverState: { + values: { + status: 'published', + // @ts-expect-error Server values use editable input, not parsed output. + age: 42, + name: 'Submitted', + }, + validationResults: [], + submissionAttempts: 1, + }, + }) + formOptions.looseSchema(schema, { + defaultValues: { status: 'draft', age: null }, + serverState: { + values: { + status: 'published', + age: '42', + // @ts-expect-error Server values cannot introduce a new null state. + name: null, + }, + validationResults: [], + submissionAttempts: 1, + }, + }) + }) + + it('merges partial nested array items into mutable schema arrays', () => { + type Value = { + users: Array<{ + name: string | null + tags: Array<{ label: string | null; count: number | undefined }> + }> + } + const schema = z.object({ + users: z.array( + z.object({ + name: z.string(), + tags: z.array(z.object({ label: z.string(), count: z.number() })), + }), + ), + }) + + const options = formOptions.looseSchema(schema, { + defaultValues: { users: [{ name: null, tags: [{ label: null }] }] }, + validators: [ + { + run: ({ value, createErrorMap }) => { + expectTypeOf(value).toEqualTypeOf() + return createErrorMap({ + fields: { 'users[0].tags[0].count': 'Required' }, + }) + }, + }, + ], + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('infers schema array items from empty defaults', () => { + type Value = { users: Array<{ name: string; age: number }> } + const schema = z.object({ + users: z.array(z.object({ name: z.string(), age: z.number() })), + }) + + const options = formOptions.looseSchema(schema, { + defaultValues: { users: [] }, + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('merges top-level null, undefined, and empty defaults with schema input', () => { + const schema = z.object({ name: z.string(), age: z.number() }) + type SchemaInput = z.input + + const nullOptions = formOptions.looseSchema(schema, { + defaultValues: null, + validators: [ + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + }, + ], + onSubmit: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + }, + }) + expectTypeOf(nullOptions.defaultValues).toEqualTypeOf() + + const undefinedOptions = formOptions.looseSchema(schema, { + defaultValues: undefined, + onSubmit: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf< + SchemaInput | undefined + >() + }, + }) + expectTypeOf(undefinedOptions.defaultValues).toEqualTypeOf< + SchemaInput | undefined + >() + + type EmptyValue = { name: string | undefined; age: number | undefined } + const emptyOptions = formOptions.looseSchema(schema, { + defaultValues: {}, + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + }) + expectTypeOf(emptyOptions.defaultValues).toEqualTypeOf() + }) + + it('preserves built-in values and nullable objects without making them partial', () => { + type Value = { + startDate: Date | null + pattern: RegExp + profile: { name: string } | null + } + const schema = z.object({ + startDate: z.date(), + pattern: z.instanceof(RegExp), + profile: z.object({ name: z.string() }), + }) + + const options = formOptions.looseSchema(schema, { + defaultValues: { startDate: null, pattern: /name/, profile: null }, + onSubmit: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(formApi.state.values).toEqualTypeOf() + }, + }) + expectTypeOf(options.defaultValues).toEqualTypeOf() + + formOptions.looseSchema(schema, { + defaultValues: { + // @ts-expect-error Dates must be complete Date values or editable placeholders. + startDate: {}, + }, + }) + formOptions.looseSchema(schema, { + defaultValues: { + // @ts-expect-error Regular expressions cannot be partial plain objects. + pattern: { source: 'name' }, + }, + }) + }) +}) + +describe('formOptions schema helpers', () => { + it('preserves configured component types through both schema helpers', () => { + type Components = { TextField: (props: { label: string }) => string } + type ComponentsOf = + TOptions extends FormOptions + ? TComponents + : never + const configuredFormOptions = formOptions as FormOptionsApi + const schema = z.object({ name: z.string() }) + + const strictOptions = configuredFormOptions.strictSchema(schema, { + defaultValues: { name: '' }, + }) + expectTypeOf< + ComponentsOf + >().toEqualTypeOf() + + const looseOptions = configuredFormOptions.looseSchema(schema, { + defaultValues: { name: null }, + }) + expectTypeOf< + ComponentsOf + >().toEqualTypeOf() + }) +}) + describe('formOptions server state', () => { it('accepts omitted, null, and initial server state without validators', () => { const options = formOptions({ defaultValues: { name: '' } }) @@ -137,7 +851,6 @@ describe('formOptions listener inference', () => { run: async ({ value, formApi, triggerFieldApi }) => { expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() expectTypeOf(formApi.state.values).toEqualTypeOf() - expectTypeOf(formApi.state.errors).toEqualTypeOf() expectTypeOf(triggerFieldApi).toEqualTypeOf< AnyFieldApi | undefined >() From fed7670154017f0c68b80786c07bd928f832bfc3 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:53:17 +0200 Subject: [PATCH 08/43] feat: add JSDoc to formOptions --- .../src/FormApi/formOptions.public.ts | 325 ++++++++++++++++-- .../src/FormApi/formServerState.public.ts | 2 +- .../src/FormApi/formSubmit.public.ts | 45 ++- .../form-core/src/validation/state.public.ts | 22 +- .../FormApi/formOptions.public.test-d.ts | 42 +-- 5 files changed, 365 insertions(+), 71 deletions(-) diff --git a/packages/form-core/src/FormApi/formOptions.public.ts b/packages/form-core/src/FormApi/formOptions.public.ts index 7e9c7613fb..4f11ad7c7a 100644 --- a/packages/form-core/src/FormApi/formOptions.public.ts +++ b/packages/form-core/src/FormApi/formOptions.public.ts @@ -1,12 +1,10 @@ -import type { - GetFormErrorTypes, - ToFormErrorTypes, -} from '../validation/state.public' +import type { GetFormErrorTypes } from '../validation/state.public' import type { ErrorVisibility } from './errorVisibility.public' import type { FormListeners } from './formListeners.public' import type { ServerFormState } from './formServerState.public' import type { FormOptionsOnSubmitFn, + FormOptionsOnSubmitInvalidFn, GetValidatorOutputs, } from './formSubmit.public' import type { FormOptionsValidators } from '../validation/formValidators.public' @@ -16,55 +14,237 @@ import type { } from '../standardSchema.public' import type { Editable, InferUnion } from '../utils.public' -declare const formOptionsComponentsSymbol: unique symbol +declare const formOptionsTypePayloadSymbol: unique symbol type TryGetValidatorOutputs = TValidators extends ReadonlyArray ? GetValidatorOutputs : [] +/** + * Configures a form's initial values, validation, listeners, and submission. + * + * Pass these options to a framework adapter's form creation API. Use + * `formOptions` when defining reusable options so values and callbacks retain + * their inferred types. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValidatorConfig - Library-managed. Do not specify explicitly. + * @typeParam TValidators - Library-managed. Do not specify explicitly. + * @typeParam TPayload - Library-managed. Do not specify explicitly. + */ export interface FormOptions< TFormData, TValidatorConfig extends unknown[], TValidators, - out TComponents, + TSubmitReturn, + out TPayload, > { - readonly [formOptionsComponentsSymbol]?: TComponents + /** + * INTERNAL USE ONLY + * + * A custom type payload that adapters need for things like form composition. Not a real runtime value. + */ + readonly [formOptionsTypePayloadSymbol]?: TPayload + /** + * The values your form starts with. + * + * `reset()` restores these values, and `isDefaultValue` checks whether the + * current values match them. + * + * You can supply a new object whenever options are updated. Defaults are + * compared deeply, so recreating equal objects and arrays leaves them unchanged. + * + * When defaults change, untouched top-level fields receive the new values. + * Values under touched top-level fields are preserved. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { + * name: '', + * age: 0, + * interests: [] as string[], + * }, + * onSubmit: ({ value }) => { + * // Use values + * }, + * }) + * ``` + */ defaultValues: TFormData + /** + * Identifies this form through `formApi.formId`. + * + * When omitted, an identifier is generated when the form is created and + * preserved across option updates. Supplying a new `formId` replaces it. + */ formId?: string + /** + * Controls when fields expose validation errors. Return `true` to show errors + * or `false` to hide them. This only affects their visibility and does not + * change the form's validity. + * + * Applies to every field unless the field supplies its own `errorVisibility`. + * When no `errorVisibility` is provided, errors are always exposed. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * errorVisibility: ({ state, fieldState }) => + * fieldState.meta.isBlurred || state.submissionAttempts > 0, + * }) + * ``` + */ errorVisibility?: ErrorVisibility> + /** + * An ordered array of validators for the complete form values. + * + * By default, validators run only on submission. See `triggers` and + * `skipOnSubmit` to change when they run. + * + * Submission outputs follow the same array order. + * + * Make sure to not dynamically change the array length. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * validators: [ + * { + * triggers: ['change'], + * run: ({ value, createErrorMap }) => { + * // Form validators can set errors on fields + * const map = createErrorMap(); + * if (!value.name.trim()) { + * map.fields.name = 'Enter a name'; + * } + * return map; + * }, + * }, + * ], + * }) + * ``` + */ validators?: TValidators & FormOptionsValidators, TValidatorConfig> + /** + * Callbacks for change, blur, submit, mount, and reset events. + * + * Use listeners for work that does not depend on other listeners. Return + * values are ignored, and returned promises are not awaited. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * listeners: [ + * { + * triggers: ['change'], + * triggerDebounceMs: 200, + * run: ({ value }) => { + * localStorage.setItem('profile-draft', JSON.stringify(value)) + * }, + * }, + * ], + * }) + * ``` + */ listeners?: FormListeners< NoInfer, - NoInfer> + NoInfer> > + /** + * Used by server-side rendering (SSR) integrations to restore form state + * from the server on the client. + */ serverState?: NoInfer> | null - // TODO infer return submit for errors + /** + * Handles submission after field and form submission validation succeeds. + * A returned promise is awaited before submission finishes. + * + * The context provides form values, validator outputs, the form instance, and + * helpers for reporting submission errors. + * + * You can also report form and field errors discovered during submission, + * such as validation errors returned by the server. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * onSubmit: async ({ value, createErrorMap }) => { + * const response = await createProfile(); + * if (response.error === 'username_taken') { + * return createErrorMap({ fields: { name: 'This name is taken' }}) + * } + * return redirect({ to: '/my-profile' }) + * }, + * }) + * ``` + */ onSubmit?: FormOptionsOnSubmitFn< NoInfer, NoInfer>, - NoInfer> + TSubmitReturn + > + /** + * Handles a failed submission, including submission validation failures, + * errors returned from `onSubmit`, and exceptions during validation or + * `onSubmit`. + * + * A returned promise is awaited before submission finishes. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * onSubmitInvalid: () => { + * // Focus on first invalid input + * document.querySelector('[aria-invalid="true"]')?.focus() + * }, + * }) + * ``` + */ + onSubmitInvalid?: FormOptionsOnSubmitInvalidFn< + NoInfer, + NoInfer> > } -export type FormOptionsStrictSchemaFn = < +/** + * Function type for creating form options whose default values match a Standard + * Schema's input type. + * + * @returns The supplied options object unchanged. + * @typeParam TPayload - Library-managed. Do not specify explicitly. + * @typeParam TSchema - Library-managed. Do not specify explicitly. + * @typeParam TValidatorConfig - Library-managed. Do not specify explicitly. + * @typeParam TValidators - Library-managed. Do not specify explicitly. + * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. + */ +export type FormOptionsStrictSchemaFn = < TSchema extends StandardSchemaV1, const TValidatorConfig extends unknown[], TValidators, + TSubmitReturn, >( schema: TSchema, options: FormOptions< NoInfer>, TValidatorConfig, TValidators, + TSubmitReturn, unknown >, ) => FormOptions< NoInfer>, TValidatorConfig, TValidators, - TComponents + TSubmitReturn, + TPayload > type LooseSchemaFormOptions< @@ -72,11 +252,13 @@ type LooseSchemaFormOptions< TFormData extends Editable, TValidatorConfig extends unknown[], TValidators, + TSubmitReturn, > = Omit< FormOptions< InferUnion, TValidatorConfig, TValidators, + TSubmitReturn, unknown >, 'defaultValues' @@ -84,35 +266,136 @@ type LooseSchemaFormOptions< defaultValues: TFormData } -export type FormOptionsLooseSchemaFn = < +/** + * Function type for creating form options from a Standard Schema with defaults + * that may omit properties or contain `null` or `undefined`. + * + * @returns The supplied options object unchanged. + * @typeParam TPayload - Library-managed. Do not specify explicitly. + * @typeParam TSchema - Library-managed. Do not specify explicitly. + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValidatorConfig - Library-managed. Do not specify explicitly. + * @typeParam TValidators - Library-managed. Do not specify explicitly. + * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. + */ +export type FormOptionsLooseSchemaFn = < TSchema extends StandardSchemaV1, const TFormData extends Editable>, const TValidatorConfig extends unknown[], TValidators, + TSubmitReturn, >( schema: TSchema, options: LooseSchemaFormOptions< StandardSchemaInput, TFormData, TValidatorConfig, - TValidators + TValidators, + TSubmitReturn >, ) => FormOptions< InferUnion>, TValidatorConfig, TValidators, - TComponents + TSubmitReturn, + TPayload > -export interface FormOptionsApi { - ( - options: FormOptions, - ): FormOptions +/** + * The form-options helper type used by `formOptions` and adapter helpers such + * as `appFormOptions` to retain registered component types. + * + * @typeParam TPayload - Library-managed. Do not specify explicitly. + */ +export interface FormOptionsApi { + /** + * Infers form values, validator settings, and callback types for reusable + * options. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValidatorConfig - Library-managed. Do not specify explicitly. + * @typeParam TValidators - Library-managed. Do not specify explicitly. + */ + < + TFormData, + const TValidatorConfig extends unknown[], + TValidators, + TSubmitReturn, + >( + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, + ): FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + TPayload + > - strictSchema: FormOptionsStrictSchemaFn - looseSchema: FormOptionsLooseSchemaFn + /** + * Defines form options whose values follow a Standard Schema's input type. + * Default values must match that type, including any required properties. + * + * The schema supplies the value types without running validation. Add it to + * `validators` when it should also validate the form. + * + * @example + * ```ts + * // schema input: { name: string; age: number } + * const profileOptions = formOptions.strictSchema(schema, { + * defaultValues: { name: '', age: 0 }, + * validators: [{ run: schema }], + * }) + * ``` + */ + strictSchema: FormOptionsStrictSchemaFn + /** + * Defines form options from a Standard Schema's input type while allowing + * defaults to omit properties or use `null` or `undefined`. + * + * These empty states remain part of the form's value types, including inside + * callbacks. This also applies to nested objects and array items. + * + * The schema supplies the value types without running validation. Add it to + * `validators` when it should also validate the form. + * + * @example + * ```ts + * // schema input: { name: string; age: number } + * const profileOptions = formOptions.looseSchema(schema, { + * defaultValues: { age: null }, + * validators: [{ run: schema }], + * }) + * ``` + */ + looseSchema: FormOptionsLooseSchemaFn } +/** + * Defines reusable form options with inferred values, validators, and callback + * types. + * + * Returns the supplied object unchanged. Pass the result to a framework + * adapter's form creation API to create a form. + * + * @example + * ```ts + * const profileOptions = formOptions({ + * defaultValues: { name: '' }, + * validators: [ + * { + * triggers: ['blur'], + * run: ({ value }) => (value.name.trim() ? undefined : 'Enter a name'), + * }, + * ], + * }) + * ``` + */ export const formOptions = ((opts) => opts) as FormOptionsApi formOptions.strictSchema = (_schema, opts) => opts diff --git a/packages/form-core/src/FormApi/formServerState.public.ts b/packages/form-core/src/FormApi/formServerState.public.ts index b46fe1c57f..700607cf0d 100644 --- a/packages/form-core/src/FormApi/formServerState.public.ts +++ b/packages/form-core/src/FormApi/formServerState.public.ts @@ -25,7 +25,7 @@ type ServerValidators = type ServerFormErrorTypes = unknown extends TValidators ? ScopedErrorTypes - : GetFormErrorTypes> + : GetFormErrorTypes, never> export interface ServerFormState { values: TFormData | undefined diff --git a/packages/form-core/src/FormApi/formSubmit.public.ts b/packages/form-core/src/FormApi/formSubmit.public.ts index acc4a03e54..58d0651875 100644 --- a/packages/form-core/src/FormApi/formSubmit.public.ts +++ b/packages/form-core/src/FormApi/formSubmit.public.ts @@ -48,7 +48,9 @@ import type { Awaitable } from '../types.public' * ``` */ export interface ValidationOutput { - /** The data supplied to `createOutput`. */ + /** + * The data supplied to `createOutput`. + */ readonly [VALIDATION_OUTPUT]: TOutput } @@ -131,12 +133,11 @@ export type ParseSubmitIssuesFn = ( export interface FormSubmitContext< TFormData, TValidatorOutputs extends readonly unknown[], - TFormError extends ScopedErrorTypes, > { /** The form values for this submission, without schema transformations applied. */ value: TFormData /** The form instance handling this submission. */ - formApi: FormApi> + formApi: FormApi>> /** * The validator outputs from this submission. * @@ -206,7 +207,41 @@ export interface FormSubmitContext< export type FormOptionsOnSubmitFn< TFormData, TValidatorOutputs extends readonly unknown[], + TSubmitReturn, +> = (context: FormSubmitContext) => TSubmitReturn + +/** + * The form values and form instance passed to `onSubmitInvalid` after a failed + * submission. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFormError - Library-managed. Do not specify explicitly. + */ +export interface FormSubmitInvalidContext< + in out TFormData, + TFormError extends ScopedErrorTypes, +> { + /** The form values for the failed submission, without schema transformations applied. */ + value: TFormData + /** The form instance handling the failed submission. */ + formApi: FormApi> +} + +/** + * Handles a failed form submission. + * + * Runs for submission validation failures, errors returned from `onSubmit`, and + * exceptions thrown during validation or `onSubmit`. + * + * A returned promise is awaited before submission finishes. If this callback + * throws or rejects, `handleSubmit()` rejects. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFormError - Library-managed. Do not specify explicitly. + */ +export type FormOptionsOnSubmitInvalidFn< + in out TFormData, TFormError extends ScopedErrorTypes, > = ( - context: FormSubmitContext, -) => Awaitable>> + context: FormSubmitInvalidContext, +) => Awaitable diff --git a/packages/form-core/src/validation/state.public.ts b/packages/form-core/src/validation/state.public.ts index 824bc727ef..5f56f710b7 100644 --- a/packages/form-core/src/validation/state.public.ts +++ b/packages/form-core/src/validation/state.public.ts @@ -1,4 +1,5 @@ import type { DeepKeys } from '../deep-keys.public' +import type { OnSubmitError } from '../FormApi/FormApi.public' import type { StandardSchemaV1, StandardSchemaV1Issue, @@ -178,9 +179,24 @@ type GetValidatorsError = ? GetValidatorError : never -export type GetFormErrorTypes = ScopedErrorTypes< - GetValidatorsError, - GetValidatorsError +type GetSubmitError = + Awaited extends infer TResult + ? TResult extends OnSubmitError + ? GetResultError + : never + : never + +type GetSelfErrors = + | GetValidatorsError + | GetSubmitError + +type GetSubfieldErrors = + | GetValidatorsError + | GetSubmitError + +export type GetFormErrorTypes = ScopedErrorTypes< + GetSelfErrors, + GetSubfieldErrors > // TODO get rid of this to complete migration diff --git a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts index a33525b894..609b4eca3f 100644 --- a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -163,9 +163,6 @@ describe('formOptions.strictSchema', () => { onSubmit: ({ value, formApi, validatorOutputs }) => { expectTypeOf(value).toEqualTypeOf() expectTypeOf(formApi.state.values).toEqualTypeOf() - expectTypeOf(formApi.state.errors).toEqualTypeOf< - Array - >() expectTypeOf(validatorOutputs).toEqualTypeOf< [number, SchemaOutput, undefined] >() @@ -463,9 +460,6 @@ describe('formOptions.looseSchema', () => { onSubmit: ({ value, formApi, validatorOutputs }) => { expectTypeOf(value).toEqualTypeOf() expectTypeOf(formApi.state.values).toEqualTypeOf() - expectTypeOf(formApi.state.errors).toEqualTypeOf< - Array - >() expectTypeOf(validatorOutputs).toEqualTypeOf< [string, SchemaOutput, SchemaOutput | undefined, undefined] >() @@ -702,32 +696,6 @@ describe('formOptions.looseSchema', () => { }) }) -describe('formOptions schema helpers', () => { - it('preserves configured component types through both schema helpers', () => { - type Components = { TextField: (props: { label: string }) => string } - type ComponentsOf = - TOptions extends FormOptions - ? TComponents - : never - const configuredFormOptions = formOptions as FormOptionsApi - const schema = z.object({ name: z.string() }) - - const strictOptions = configuredFormOptions.strictSchema(schema, { - defaultValues: { name: '' }, - }) - expectTypeOf< - ComponentsOf - >().toEqualTypeOf() - - const looseOptions = configuredFormOptions.looseSchema(schema, { - defaultValues: { name: null }, - }) - expectTypeOf< - ComponentsOf - >().toEqualTypeOf() - }) -}) - describe('formOptions server state', () => { it('accepts omitted, null, and initial server state without validators', () => { const options = formOptions({ defaultValues: { name: '' } }) @@ -886,15 +854,7 @@ describe('formOptions listener inference', () => { expectTypeOf(formApi.state.errors).toEqualTypeOf< Array<{ message: string; code: 'form' }> >() - expectTypeOf(formApi).toEqualTypeOf< - FormApi< - typeof value, - FormErrorTypes< - { message: string; code: 'form' }, - { message: string; code: 'field' } - > - > - >() + expectTypeOf(formApi.state.values).toEqualTypeOf<{ name: string }>() }, }, ], From 4cc0eeef58de88569b1e48ee93c067c54e581258 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:50:02 +0200 Subject: [PATCH 09/43] feat: add events.public.ts --- .../src/FormApi/formListeners.public.ts | 5 +- .../src/FormApi/formServerState.public.ts | 2 +- packages/form-core/src/events.public.ts | 57 +++++++++++++++++++ .../src/validation/formValidators.public.ts | 13 ++++- 4 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 packages/form-core/src/events.public.ts diff --git a/packages/form-core/src/FormApi/formListeners.public.ts b/packages/form-core/src/FormApi/formListeners.public.ts index 8b222dd6f3..b42a7f3b9a 100644 --- a/packages/form-core/src/FormApi/formListeners.public.ts +++ b/packages/form-core/src/FormApi/formListeners.public.ts @@ -1,9 +1,6 @@ import type { AnyFieldApi } from '../FieldApi/FieldApi.public' import type { Listener } from '../listeners.public' -import type { - ClientSideEvents, - PreprocessEvents, -} from '../new-validation.public' +import type { ClientSideEvents, PreprocessEvents } from '../events.public' // TODO finalize migration import type { FormErrorTypes } from '../validation.public' import type { diff --git a/packages/form-core/src/FormApi/formServerState.public.ts b/packages/form-core/src/FormApi/formServerState.public.ts index 700607cf0d..80dca6ac88 100644 --- a/packages/form-core/src/FormApi/formServerState.public.ts +++ b/packages/form-core/src/FormApi/formServerState.public.ts @@ -1,4 +1,4 @@ -import type { ServerSideEvents } from '../new-validation.public' +import type { ServerSideEvents } from '../events.public' // TODO finalize migration import type { FormValidateResultFromErrorTypes } from '../validation.public' import type { diff --git a/packages/form-core/src/events.public.ts b/packages/form-core/src/events.public.ts new file mode 100644 index 0000000000..53d76c36ef --- /dev/null +++ b/packages/form-core/src/events.public.ts @@ -0,0 +1,57 @@ +/** + * Events that identify why a validator runs. + * + * - `'change'`: A value changed. + * - `'blur'`: A field was blurred. + * - `'server'`: Form values are being validated on the server. + * - `'submit'`: A client form submission was attempted. + * - `'mount'`: Initial values are being validated. + */ +export type ValidatorEvents = 'change' | 'blur' | 'server' | 'submit' | 'mount' + +// Alternative event for this: 'reset' +/** + * Events for validating initial values during setup. + * + * For form validators, `'mount'` runs when the form is constructed. + */ +export type PreprocessEvents = Extract + +/** Events for validating form values on the server. */ +export type ServerSideEvents = Extract + +/** + * Value change, field blur, and client submission events whose validator + * callbacks receive the form API. + */ +export type ClientSideEvents = Exclude< + ValidatorEvents, + ServerSideEvents | PreprocessEvents +> + +/** + * Events accepted by a validator's `triggers` array. + * + * Submission is controlled separately by `skipOnSubmit`. + */ +export type ValidatorTriggers = Exclude + +/** + * Mount and server triggers that accept a boolean `when` condition. + * Callbacks are not supported for these triggers. + */ +export type StaticValidatorTriggers = Extract< + ValidatorTriggers, + ServerSideEvents | PreprocessEvents +> + +/** + * Change and blur triggers that accept a boolean or callback `when` condition. + * + * A callback reads the current form values and state; return `true` to run + * validation or `false` to skip it for the matching event. + */ +export type DynamicValidatorTriggers = Extract< + ValidatorTriggers, + ClientSideEvents +> diff --git a/packages/form-core/src/validation/formValidators.public.ts b/packages/form-core/src/validation/formValidators.public.ts index 191e723216..8534a04d5e 100644 --- a/packages/form-core/src/validation/formValidators.public.ts +++ b/packages/form-core/src/validation/formValidators.public.ts @@ -7,7 +7,7 @@ import type { StaticValidatorTriggers, ValidatorEvents, ValidatorTriggers, -} from '../new-validation.public' +} from '../events.public' import type { StandardSchemaV1 } from '../standardSchema.public' import type { CreateErrorMapFn, @@ -303,6 +303,17 @@ interface FormValidatorRunner { * @default 'field' */ fieldErrorTarget?: 'field' | 'field-and-self' | 'self' + /** + * If `true`, skips this validator and all later validators when earlier + * validation in the current run has failed. + * + * Waits for earlier asynchronous validators to finish before checking for + * failures. If `false`, earlier failures do not prevent this validator from + * running. + * + * @default false + */ + bailIfInvalid?: boolean } /** From 21e8e53e0d1719cfee869537df9ecf353ae5e386 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:15:52 +0200 Subject: [PATCH 10/43] broken: begin migration of form options in core --- .../form-core/src/FieldApi/FieldApi.lib.ts | 15 +- .../form-core/src/FieldApi/FieldApi.public.ts | 23 +- .../form-core/src/FieldApi/fieldState.lib.ts | 15 +- packages/form-core/src/FormApi/FormApi.lib.ts | 96 ++-- .../form-core/src/FormApi/FormApi.public.ts | 441 +---------------- .../src/FormApi/errorVisibility.public.ts | 4 +- .../src/FormApi/formListeners.public.ts | 19 +- .../src/FormApi/formOptions.public.ts | 14 +- .../src/FormApi/formServerState.public.ts | 77 +-- .../form-core/src/FormApi/formState.lib.ts | 48 +- .../src/FormApi/formSubmit.public.ts | 62 +-- .../form-core/src/FormApi/handleSubmit.lib.ts | 57 ++- .../src/FormGroupApi/FormGroupApi.lib.ts | 35 +- .../src/FormGroupApi/FormGroupApi.public.ts | 55 ++- .../form-core/src/ListenerInstance.lib.ts | 3 +- .../form-core/src/ValidatorInstance.lib.ts | 49 +- packages/form-core/src/createErrorMap.lib.ts | 7 +- .../form-core/src/defaultOptions.public.ts | 16 +- packages/form-core/src/index.ts | 10 +- packages/form-core/src/listeners.lib.ts | 12 +- packages/form-core/src/listeners.public.ts | 178 +------ .../form-core/src/new-validation.public.ts | 20 + packages/form-core/src/ssr.lib.ts | 46 +- packages/form-core/src/ssr.public.ts | 100 ---- packages/form-core/src/standardSchema.lib.ts | 10 +- packages/form-core/src/utils.lib.ts | 2 +- packages/form-core/src/utils.public.ts | 362 +------------- packages/form-core/src/validation.public.ts | 450 ++---------------- .../form-core/src/validation/errors.lib.ts | 17 +- .../form-core/src/validation/execution.lib.ts | 21 +- .../src/validation/formValidators.public.ts | 1 + .../form-core/src/validation/mount.lib.ts | 13 +- .../form-core/src/validation/pipeline.lib.ts | 12 +- .../form-core/src/validation/state.public.ts | 37 +- .../form-core/src/validationOutput.lib.ts | 6 +- 35 files changed, 515 insertions(+), 1818 deletions(-) create mode 100644 packages/form-core/src/new-validation.public.ts delete mode 100644 packages/form-core/src/ssr.public.ts diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index 55e6e41376..a87f0c161e 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -65,7 +65,6 @@ import type { } from '../ListenerInstance.lib' import type { FieldApi, FieldApiOptions } from './FieldApi.public' import type { - ErrorVisibility, FieldErrors, FieldValidateResult, FieldValidator, @@ -79,6 +78,8 @@ import type { InternalFieldMeta, InternalFieldState, } from './fieldState.lib' +import type { ClientSideEvents } from '../events.public' +import type { ErrorVisibilityFn } from '../FormApi/errorVisibility.public' export type AnyFieldApiOptions = FieldApiOptions< any, @@ -307,7 +308,7 @@ export type InternalFieldListenerInstance = InternalListenerInstance< AnyInternalFieldApi > export type InternalFieldValidatorInstance = InternalValidatorInstance< - AnyFieldValidator, + 'field', AnyInternalFieldApi, AnyInternalFieldApi, AnyInternalFieldApi @@ -325,7 +326,7 @@ export class InternalFieldApi< _atoms: FieldAtoms /** Stable runtime instances for this field's validator definitions. */ _validatorInstances: InternalValidatorInstances< - AnyFieldValidator, + 'field', AnyInternalFieldApi, AnyInternalFieldApi, AnyInternalFieldApi @@ -336,7 +337,7 @@ export class InternalFieldApi< AnyInternalFieldApi, AnyInternalFieldApi > - _errorVisibility: ErrorVisibility | undefined + _errorVisibility: ErrorVisibilityFn | undefined _errorBoundary: boolean /** The form group occupying this trie node. */ _formGroup: AnyInternalFormGroupApi | null = null @@ -524,7 +525,7 @@ export class InternalFieldApi< owner: this, }) this._validatorInstances = reconcileValidatorInstances< - AnyFieldValidator, + 'field', AnyInternalFieldApi, AnyInternalFieldApi, AnyInternalFieldApi @@ -626,7 +627,7 @@ export class InternalFieldApi< ? (resolvedOptions.validators as Array) : null this._validatorInstances = reconcileValidatorInstances< - AnyFieldValidator, + 'field', AnyInternalFieldApi, AnyInternalFieldApi, AnyInternalFieldApi @@ -758,7 +759,7 @@ export class InternalFieldApi< * Triggers validation for this field and all parent fields, * eventually calling form validation. */ - _triggerValidationCascade(event: 'change' | 'blur' | 'submit'): void { + _triggerValidationCascade(event: ClientSideEvents): void { if (this._isKilled) return const seenValidatorFields = new WeakSet() diff --git a/packages/form-core/src/FieldApi/FieldApi.public.ts b/packages/form-core/src/FieldApi/FieldApi.public.ts index eef81964f4..50af269fcf 100644 --- a/packages/form-core/src/FieldApi/FieldApi.public.ts +++ b/packages/form-core/src/FieldApi/FieldApi.public.ts @@ -2,14 +2,17 @@ import type { TryGetArrayElementType } from '../deep-keys.public' import type { FieldUpdateOptions, Updater } from '../types.public' import type { FormApi } from '../FormApi/FormApi.public' import type { - ErrorVisibility, FieldValidators, - FormErrorTypes, ToFieldError, FieldErrors as ValidationFieldErrors, } from '../validation.public' import type { FieldListeners } from '../listeners.public' import type { ReadonlyAtom } from '@tanstack/store' +import type { + ScopedErrorTypes, + ToFormErrorTypes, +} from '../validation/state.public' +import type { ErrorVisibilityFn } from '../FormApi/errorVisibility.public' export interface BaseFieldMeta { isTouched: boolean @@ -125,12 +128,12 @@ export interface FieldApi< in out TFieldValue, in out TFieldError, in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, > { /** * The form that owns this field. */ - form: FormApi + form: FormApi /** * The name of the field. @@ -229,10 +232,10 @@ export interface FieldApiOptions< >, in out TGroupFieldError, in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, > { name: TFieldName - errorVisibility?: ErrorVisibility + errorVisibility?: ErrorVisibilityFn /** * Route descendant field errors from form and form group validators to this field. */ @@ -242,8 +245,12 @@ export interface FieldApiOptions< TFieldData, TFieldName, TFieldValue, - ToFieldError, TGroupFieldError, TFormErrorTypes>, + ToFieldError< + NoInfer, + TGroupFieldError, + ToFormErrorTypes + >, TFormData, - TFormErrorTypes + TFormError > } diff --git a/packages/form-core/src/FieldApi/fieldState.lib.ts b/packages/form-core/src/FieldApi/fieldState.lib.ts index 2d0940b24b..d6d336f237 100644 --- a/packages/form-core/src/FieldApi/fieldState.lib.ts +++ b/packages/form-core/src/FieldApi/fieldState.lib.ts @@ -8,13 +8,14 @@ import type { FieldState as PublicFieldState, SubfieldsMeta, } from './FieldApi.public' -import type { - ErrorVisibility, - ErrorVisibilityFieldState, - ValidationIssue, -} from '../validation.public' import type { ValidationSourceErrorMap } from '../validation' import type { Atom, ReadonlyAtom } from '@tanstack/store' +import type { ValidationIssue } from '../validation/state.public' +import type { + ErrorVisibilityContext, + ErrorVisibilityFieldState, + ErrorVisibilityFn, +} from '../FormApi/errorVisibility.public' export type ChildContributionKey = RootCounterContributionKey | 'dirty' | 'error' @@ -215,12 +216,12 @@ function canReusePreviousMeta({ function getErrorVisibility( field: AnyInternalFieldApi | undefined, -): ErrorVisibility | undefined { +): ErrorVisibilityFn | undefined { return field?._errorVisibility ?? field?.form._options.errorVisibility } function shouldDisplayErrors( - errorVisibility: ErrorVisibility | undefined, + errorVisibility: ErrorVisibilityFn | undefined, field: AnyInternalFieldApi | undefined, baseMeta: InternalBaseFieldMeta, value?: any, diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 4096aca4b3..6d7e93c7e9 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -48,12 +48,7 @@ import { getFormStateSnapshot, reconcileFormErrorFields, } from './formState.lib' -import type { - FormApi, - FormOptions, - FormResetOptions, - FormState, -} from './FormApi.public' +import type { FormApi, FormResetOptions, FormState } from './FormApi.public' import type { FormErrorMeta } from './formState.lib' import type { DeepKeys } from '../deep-keys.public' import type { FormValidatorPipelineResult, PipelineResult } from '../validation' @@ -74,24 +69,30 @@ import type { } from '../types.lib' import type { Atom, ReadonlyAtom } from '@tanstack/store' import type { Updater } from '../types.public' -import type { - FormValidateResult, - FormValidationError, - FormValidator, - FormValidators, - ToFormErrorTypes, - ValidationIssue, - ValidationTrigger, -} from '../validation.public' -import type { AnyFormListener, FormListenerTriggers } from '../listeners.public' import type { InternalListenerInstances } from '../ListenerInstance.lib' -import type { ServerFormState } from '../ssr.public' import type { InternalValidatorInstance, InternalValidatorInstances, } from '../ValidatorInstance.lib' import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' import type { DefaultOptions } from '../defaultOptions.public' +import type { + FormValidationFailure, + FormValidationResult, + ScopedErrorTypes, + ToScopedErrorTypes, + ValidationIssue, +} from '../validation/state.public' +import type { FormOptions } from './formOptions.public' +import type { ServerFormState } from './formServerState.public' +import type { + ClientSideEvents, + DynamicValidatorTriggers, +} from '../events.public' +import type { + AnyFormListener, + FormListenerTriggers, +} from './formListeners.public' export interface FormMetaAtoms { isDirty: Atom @@ -144,18 +145,15 @@ function createInitialFormMetaAtoms(): FormMetaAtoms { } } -export type AnyInternalFormApi = InternalFormApi +export type AnyInternalFormApi = InternalFormApi export type InternalFormValidatorInstance = InternalValidatorInstance< - FormValidator, + 'form', AnyInternalFormApi, AnyInternalFieldApi > -type InternalFormOptions< - TFormData, - TFormValidators extends FormValidators, - TSubmitReturn, -> = FormOptions & { +type FormOptionsOfType = FormOptions +type InternalFormOptions = FormOptionsOfType & { formId: string } @@ -200,13 +198,9 @@ function notifyDevtoolsDefaultValuesUpdate(form: AnyInternalFormApi): void { visitAllFormFields(form._fieldRootNode, (current) => updateField(current)) } -export class InternalFormApi< +export class InternalFormApi implements FormApi< TFormData, - const TFormValidators extends FormValidators, - TSubmitReturn, -> implements FormApi< - TFormData, - ToFormErrorTypes + ScopedErrorTypes > { /** * Devtools use this to show what version of the library is being used. @@ -217,17 +211,15 @@ export class InternalFormApi< get _FieldApi(): typeof InternalFieldApi { return InternalFieldApi } - atom: ReadonlyAtom< - FormState> - > + atom: ReadonlyAtom>> _atoms: FormAtoms _fieldRootNode: InternalRootFieldApi _defaultValueCache: DefaultValueCacheEntry | null = null readonly _defaultOptions: DefaultOptions | undefined - _options: InternalFormOptions + _options: InternalFormOptions /** Stable runtime instances correlated with `_options.validators` by slot. */ _validatorInstances: InternalValidatorInstances< - TFormValidators[number], + 'form', AnyInternalFormApi, AnyInternalFieldApi > @@ -242,12 +234,9 @@ export class InternalFormApi< AnyFormListener, AnyInternalFormApi > - _lastServerState: ServerFormState | null = null + _lastServerState: ServerFormState | null = null - get state(): FormState< - TFormData, - ToFormErrorTypes - > { + get state(): FormState { return this.atom.get() } @@ -304,7 +293,7 @@ export class InternalFormApi< } constructor( - options: FormOptions, + options: FormOptionsOfType, defaultOptions?: DefaultOptions, ) { this._defaultOptions = defaultOptions @@ -336,7 +325,7 @@ export class InternalFormApi< owner: this, }) this._validatorInstances = reconcileValidatorInstances< - TFormValidators[number], + 'form', AnyInternalFormApi, AnyInternalFieldApi >({ @@ -420,7 +409,7 @@ export class InternalFormApi< this._notifyFormListener('reset', null) } - _update(options: FormOptions) { + _update(options: FormOptions) { const resolvedOptions = resolveDefaultOptions( options, this._defaultOptions?.form, @@ -449,7 +438,7 @@ export class InternalFormApi< }) this._validatorInstances = reconcileValidatorInstances< - TFormValidators[number], + any, AnyInternalFormApi, AnyInternalFieldApi >({ @@ -712,14 +701,14 @@ export class InternalFormApi< _clearEventErrors( field: AnyInternalFieldApi | null, sourceEvent: string, - event: Exclude, + event: Exclude, ) { const validatorInstancesToClear: Array = [] for (const validatorInstance of this._validatorInstances ?? []) { const validator = validatorInstance.definition - const runsOnEvent = validator.triggers.some((trigger) => + const runsOnEvent = validator.triggers?.some((trigger) => isValidationTriggerEnabled(trigger, { scope: 'form', event, @@ -934,7 +923,7 @@ export class InternalFormApi< } _processValidationResult( - result: PipelineResult>, + result: PipelineResult>, sourceEvent: string, ) { const validatorInstance = @@ -984,7 +973,7 @@ export class InternalFormApi< } _processSubmitValidationResult( - result: FormValidateResult, + result: FormValidationResult, sourceEvent: string, ): void { const parsedResult = parseValidationResult(result) @@ -1042,7 +1031,8 @@ export class InternalFormApi< this._setValidationCount((count) => count + 1) const { didRun, asyncPromise } = runFormMountValidatorPipeline({ - pipeline, + // TODO investigate this error. Leftover? + pipeline: pipeline as any, formApi: this, onResult: (result) => this._processValidationResult(result, 'mount'), }) @@ -1063,7 +1053,7 @@ export class InternalFormApi< } async _runFormValidation( - signal: ValidationTrigger, + signal: ClientSideEvents, opts?: FieldApiOverrideOptions & { onResult?: boolean hasFailedBefore?: boolean @@ -1095,7 +1085,7 @@ export class InternalFormApi< triggerFieldApi: opts?.fieldApiOverride ?? undefined, }, hasFailedBefore: opts?.hasFailedBefore ?? false, - pipeline, + pipeline: pipeline as never, onResult: opts?.onResult !== false ? (result) => this._processValidationResult(result, signal) @@ -1111,7 +1101,7 @@ export class InternalFormApi< } validate = async ( - signal: ValidationTrigger, + signal: ClientSideEvents, opts?: FieldApiOverrideOptions & { onResult?: boolean hasFailedBefore?: boolean @@ -1125,7 +1115,7 @@ export class InternalFormApi< _handleSubmitPromise: Promise | null = null - handleSubmit = (): Promise>> => { + handleSubmit = (): Promise>> => { if (this._handleSubmitPromise) return this._handleSubmitPromise this._notifyFormListener('submit', null) diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index 5567228ead..81049c8ce9 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -1,424 +1,11 @@ import type { ReadonlyAtom } from '@tanstack/store' -import type { - ConfigurableValidationTrigger, - ErrorVisibility, - FormErrorTypes, - FormErrors, - FormValidationError, - FormValidators, - ParsedStandardSchemaIssues, - ToFormErrorTypes, - ToFormOutputs, - ValidationErrorMap, -} from '../validation.public' -import type { StandardSchemaV1Issue } from '../standardSchema.public' -import type { FormListeners } from '../listeners.public' import type { FormApiArrayMethods } from './FormApiArrayMethods.types.public' import type { FormApiFieldMethods } from './FormApiFieldMethods.types.public' -import type { ServerFormState } from '../ssr.public' -import type { submitErrorSymbol } from './handleSubmit.lib' - -/** - * A validation error recognized as an `onSubmit` failure. - * - * Create this value with the `createValidationError` or `createErrorMap` helper - * provided to `onSubmit`, then return it from the callback so its form- and - * field-level errors are added to validation state. - * - * @example - * ```ts - * formOptions({ - * defaultValues: { name: '' }, - * onSubmit: async ({ value, createValidationError }) => { - * const result = await saveUser(value) - * - * if (!result.ok) { - * return createValidationError(result.error) - * } - * }, - * }) - * ``` - * - * @typeParam TFormValidationError - Library-managed. Do not specify explicitly. - */ -export type OnSubmitError< - TFormValidationError extends FormValidationError, -> = { - readonly [submitErrorSymbol]: TFormValidationError -} - -/** - * Wraps a validation error for processing as an `onSubmit` failure without mutating it. - * - * Return the result from `onSubmit` to add its form- and field-level errors to - * validation state. - * - * @param error - The form- or field-level validation error to wrap. - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TError - Library-managed. Do not specify explicitly. - */ -export type CreateValidationErrorFn = < - TError extends FormValidationError, ->( - error: TError, -) => OnSubmitError - -/** - * Creates an error map that can be returned directly from `onSubmit`. - * - * Build a mutable map or pass Standard Schema issues to route them using the - * submitted values. Narrow possibly undefined issue lists before calling this - * helper. Empty maps indicate success. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export interface CreateSubmitErrorMapFn { - /** Creates a mutable submission error map with an empty `fields` object. */ - (): ValidationErrorMap & - OnSubmitError> - /** Reuses the supplied map, adding an empty `fields` object if it is missing. */ - ( - initial: Partial>, - ): ValidationErrorMap & - OnSubmitError> - /** Routes issues while preserving Standard Schema issue arrays in the result. */ - ( - issues: ReadonlyArray, - ): ParsedStandardSchemaIssues & - OnSubmitError> -} - -/** - * Context passed to `onSubmit` after submission validation succeeds. - * - * @example - * ```ts - * { - * // ... - * onSubmit: async ({ value, createValidationError }) => { - * const result = await saveUser(value) - * - * if (!result.ok) { - * return createValidationError(result.error) - * } - * }, - * } - * ``` - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TOutputs - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export interface FormSubmitContext< - in out TFormData, - out TOutputs, - in out TFormErrorTypes extends FormErrorTypes, -> { - /** The form values for this submission. */ - value: TFormData - /** The form API handling this submission. */ - formApi: FormApi - /** - * Schema outputs and `createOutput` payloads from this submission, ordered by - * validator index. Skipped validators and successful returns without an output - * contribute `undefined`. - * - * @example - * ```ts - * { - * // ... - * onSubmit: async ({ validatorOutputs }) => { - * const validatedUser = validatorOutputs[0] - * await saveUser(validatedUser) - * }, - * } - * ``` - */ - validatorOutputs: TOutputs - /** - * Creates a validation error that can be returned from `onSubmit`. - * - * Return the created error from `onSubmit` to add its form- and field-level - * errors to validation state. - * - * @example - * ```ts - * { - * // ... - * onSubmit: async ({ value, createValidationError }) => { - * const result = await saveUser(value) - * - * if (result.status === 409) { - * return createValidationError({ - * form: 'A user with this email already exists', - * fields: {}, - * }) - * } - * }, - * } - * ``` - */ - createValidationError: CreateValidationErrorFn - /** - * Builds an error map or routes Standard Schema issues for `onSubmit`. - * - * Issue paths are mapped to fields in the submitted value. Return the parsed - * error from `onSubmit` to add those errors to validation state. - * - * @example - * ```ts - * { - * // ... - * onSubmit: async ({ value, createErrorMap }) => { - * const result = zodSchema.safeParse(value) - * - * if (!result.success) { - * return createErrorMap(result.error.issues) - * } - * }, - * } - * ``` - */ - createErrorMap: CreateSubmitErrorMapFn -} - -/** - * Handles a form submission after submission validation succeeds. - * - * A returned promise is awaited before submission finishes. Return an error - * created by the submission context's `createValidationError` or `createErrorMap` - * helper to mark the submission as invalid. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. - */ -export type FormSubmitFn< - in out TFormData, - in out TFormValidators extends FormValidators, - in out TSubmitReturn, -> = ( - context: FormSubmitContext< - TFormData, - ToFormOutputs, - ToFormErrorTypes - >, -) => TSubmitReturn - -/** - * Context passed to `onSubmitInvalid` when a submission fails. - * - * @example - * ```ts - * { - * // ... - * onSubmitInvalid: () => { - * document - * .querySelector('[aria-invalid="true"]') - * ?.focus() - * }, - * } - * ``` - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export interface FormSubmitInvalidContext< - in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, -> { - /** The form values for the failed submission. */ - value: TFormData - /** The form API handling the failed submission. */ - formApi: FormApi -} - -/** - * Form options whose data, validator, and submission return types are erased. - * - * Use this alias only when reusable code does not need type-safe access to - * values, validators, or submission results. - */ -export type AnyFormOptions = FormOptions - -declare const formOptionsComponentsSymbol: unique symbol - -/** - * Configures initial values, validation, listeners, and submission. - * - * Pass these options to a framework adapter's form creation API. Use - * `formOptions` when declaring them separately so data and validator types - * remain inferred. - * - * @example - * ```ts - * const profileFormOptions = formOptions({ - * defaultValues: { name: '' }, - * onSubmit: async ({ value }) => { - * await saveProfile(value) - * }, - * }) - * ``` - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. - * @typeParam TComponents - Library-managed. Do not specify explicitly. - */ -export interface FormOptions< - in out TFormData, - in out TFormValidators extends FormValidators, - in out TSubmitReturn, - out TComponents, -> { - readonly [formOptionsComponentsSymbol]?: TComponents - /** - * A stable identifier for this form. - * - * When omitted, an identifier is generated and preserved across option - * updates until a new `formId` is supplied. Read the supplied or generated - * identifier from `formApi.formId`. - */ - formId?: string - /** - * Initial values and the source of the inferred data shape. - * - * They also define the reset baseline and what `isDefaultValue` compares - * against. - * - * **Async initial values:** The passed value may change over time. While data - * is loading, pass fallback values containing the complete data shape, then - * pass the resolved values when they become available. - * - * When this option changes, untouched top-level values adopt the new defaults - * while values under touched top-level fields are preserved. - */ - defaultValues: TFormData - /** - * Controls when fields expose validation errors through public state. - * - * This is the default policy for every field. A field can override it with a field-level - * `errorVisibility` option. - * - * When omitted, errors are always exposed. - * - * @example - * ```ts - * errorVisibility: ({ state, fieldState }) => - * fieldState.meta.isBlurred || state.submissionAttempts > 0, - * ``` - */ - errorVisibility?: ErrorVisibility< - TFormData, - ToFormErrorTypes - > - /** - * An ordered pipeline of form-level validators. - * - * Validators run for their configured triggers and, by default, during - * submission. Keep the array length stable after initialization so - * validator-indexed errors and schema outputs remain aligned. - * - * @example - * ```ts - * validators: [ - * { - * triggers: ['change'], - * run: ({ value, createErrorMap }) => { - * if (value.name) { - * return null - * } - * - * return createErrorMap({ fields: { name: 'Name is required' } }) - * }, - * }, - * ], - * ``` - */ - validators?: TFormValidators - /** - * Listener configurations for change, blur, submit, mount, and reset events. - * - * Matching listeners are evaluated in array order. Their return values are - * ignored, and returned promises are not awaited. - * - * @example - * ```ts - * listeners: [ - * { - * triggers: ['change'], - * triggerDebounceMs: 200, - * run: ({ value }) => { - * saveDraft(value) - * }, - * }, - * ], - * ``` - */ - listeners?: FormListeners< - TFormData, - ToFormErrorTypes - > - /** - * Server-validation state supplied by a server or SSR adapter during - * hydration. - * - * Pass a failed result's `serverState` through unchanged. Constructing or - * mutating this state directly is discouraged. - * - * @example - * ```ts - * serverState: failedResult.serverState, - * ``` - */ - serverState?: ServerFormState< - NoInfer, - NoInfer - > | null - /** - * Called after submission validation succeeds. - * - * Return an error created with `createValidationError` or `createErrorMap` to - * mark the submission as invalid. A returned promise is awaited before - * submission finishes. If the callback throws, `onSubmitInvalid` is called. - * - * @example - * ```ts - * { - * // ... - * onSubmit: async ({ value }) => { - * await saveUser(value) - * }, - * } - * ``` - */ - onSubmit?: FormSubmitFn - /** - * Called after an invalid submission is detected. - * - * This includes validation failures, errors returned from `onSubmit`, and - * exceptions thrown during validation or submission. A returned promise is - * awaited before submission finishes. - * - * @example - * ```ts - * { - * // ... - * onSubmitInvalid: () => { - * document - * .querySelector('[aria-invalid="true"]') - * ?.focus() - * }, - * } - * ``` - */ - onSubmitInvalid?: ( - context: FormSubmitInvalidContext< - TFormData, - ToFormErrorTypes - >, - ) => void | Promise -} +import type { + FormValidationFailure, + ScopedErrorTypes, +} from '../validation/state.public' +import type { DynamicValidatorTriggers } from '../events.public' /** * A snapshot of current values, validation status, and submission metadata. @@ -431,7 +18,8 @@ export interface FormOptions< */ export interface FormState< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + // TODO in out + TFormError extends ScopedErrorTypes, > { /** * Current values after field edits, resets, or default-value updates. @@ -481,7 +69,7 @@ export interface FormState< * Errors routed to fields are exposed through the corresponding field APIs * and are not included here. */ - errors: FormErrors + errors: Array /** * Whether validation state contains no form- or field-level errors. * @@ -610,7 +198,7 @@ export interface FormResetOptions { * @typeParam TFormData - Library-managed. Do not specify explicitly. */ export type HandleSubmitFn = () => Promise< - Array> + Array> > /** @@ -644,7 +232,8 @@ export type ResetFn = ( */ export interface FormApi< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + // TODO in out? + TFormError extends ScopedErrorTypes, > extends FormApiFieldMethods, FormApiArrayMethods { /** @@ -653,9 +242,9 @@ export interface FormApi< * Subscribe to this atom to observe state changes. For an imperative read, * use `state`. */ - atom: ReadonlyAtom> + atom: ReadonlyAtom> /** Current values, validation status, and submission metadata. */ - readonly state: FormState + readonly state: FormState /** The current baseline values used by `reset()` and `isDefaultValue`. */ readonly defaultValues: TFormData /** @@ -682,8 +271,8 @@ export interface FormApi< * ran. Unlike `state.errors`, these results are not flattened. */ validate: ( - signal: ConfigurableValidationTrigger, - ) => Promise>> + signal: DynamicValidatorTriggers, + ) => Promise>> /** * Runs submission validation and submits current values when validation diff --git a/packages/form-core/src/FormApi/errorVisibility.public.ts b/packages/form-core/src/FormApi/errorVisibility.public.ts index 18c00ba345..7d0eb2b4f1 100644 --- a/packages/form-core/src/FormApi/errorVisibility.public.ts +++ b/packages/form-core/src/FormApi/errorVisibility.public.ts @@ -30,10 +30,10 @@ export interface ErrorVisibilityFieldState { } export interface ErrorVisibilityContext { - state: FormState> + state: FormState fieldState: ErrorVisibilityFieldState } -export type ErrorVisibility = ( +export type ErrorVisibilityFn = ( context: ErrorVisibilityContext, ) => boolean diff --git a/packages/form-core/src/FormApi/formListeners.public.ts b/packages/form-core/src/FormApi/formListeners.public.ts index b42a7f3b9a..1835dd6de1 100644 --- a/packages/form-core/src/FormApi/formListeners.public.ts +++ b/packages/form-core/src/FormApi/formListeners.public.ts @@ -1,8 +1,6 @@ import type { AnyFieldApi } from '../FieldApi/FieldApi.public' import type { Listener } from '../listeners.public' import type { ClientSideEvents, PreprocessEvents } from '../events.public' -// TODO finalize migration -import type { FormErrorTypes } from '../validation.public' import type { ScopedErrorTypes, ToFormErrorTypes, @@ -13,28 +11,27 @@ export type FormListenerTriggers = ClientSideEvents | PreprocessEvents | 'reset' export interface FormListenerContext< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > { - formApi: FormApi + formApi: FormApi value: TFormData triggerFieldApi?: AnyFieldApi } -export type FormListenerFn< - TFormData, - TFormErrorTypes extends FormErrorTypes, -> = (context: FormListenerContext) => void +export type FormListenerFn = ( + context: FormListenerContext, +) => void export type AnyFormListener = FormListener export interface FormListener< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > extends Listener { - run: FormListenerFn + run: FormListenerFn } export type FormListeners< TFormData, TFormError extends ScopedErrorTypes, -> = Array>> +> = Array> diff --git a/packages/form-core/src/FormApi/formOptions.public.ts b/packages/form-core/src/FormApi/formOptions.public.ts index 4f11ad7c7a..bedf0216d3 100644 --- a/packages/form-core/src/FormApi/formOptions.public.ts +++ b/packages/form-core/src/FormApi/formOptions.public.ts @@ -1,5 +1,5 @@ import type { GetFormErrorTypes } from '../validation/state.public' -import type { ErrorVisibility } from './errorVisibility.public' +import type { ErrorVisibilityFn } from './errorVisibility.public' import type { FormListeners } from './formListeners.public' import type { ServerFormState } from './formServerState.public' import type { @@ -97,7 +97,7 @@ export interface FormOptions< * }) * ``` */ - errorVisibility?: ErrorVisibility> + errorVisibility?: ErrorVisibilityFn> /** * An ordered array of validators for the complete form values. * @@ -160,7 +160,7 @@ export interface FormOptions< * Used by server-side rendering (SSR) integrations to restore form state * from the server on the client. */ - serverState?: NoInfer> | null + serverState?: NoInfer> | null /** * Handles submission after field and form submission validation succeeds. * A returned promise is awaited before submission finishes. @@ -400,3 +400,11 @@ export const formOptions = ((opts) => opts) as FormOptionsApi formOptions.strictSchema = (_schema, opts) => opts formOptions.looseSchema = (_schema, opts) => opts as never + +/** + * Form options whose data, validator, and submission return types are erased. + * + * Use this alias only when reusable code does not need type-safe access to + * values, validators, or submission results. + */ +export type AnyFormOptions = FormOptions diff --git a/packages/form-core/src/FormApi/formServerState.public.ts b/packages/form-core/src/FormApi/formServerState.public.ts index 80dca6ac88..d479dd44ff 100644 --- a/packages/form-core/src/FormApi/formServerState.public.ts +++ b/packages/form-core/src/FormApi/formServerState.public.ts @@ -1,42 +1,55 @@ import type { ServerSideEvents } from '../events.public' -// TODO finalize migration -import type { FormValidateResultFromErrorTypes } from '../validation.public' -import type { - GetFormErrorTypes, - ScopedErrorTypes, - ToFormErrorTypes, -} from '../validation/state.public' +import type { FormValidationResult } from '../validation/state.public' +import type { GetValidatorOutputs } from './formSubmit.public' -type ServerValidator = unknown extends TValidator - ? TValidator - : TValidator extends { triggers?: ReadonlyArray } - ? Extract< - TTrigger, - ServerSideEvents | { trigger: ServerSideEvents } - > extends never - ? never - : TValidator - : never - -type ServerValidators = - TValidators extends ReadonlyArray - ? Array> - : TValidators - -type ServerFormErrorTypes = unknown extends TValidators - ? ScopedErrorTypes - : GetFormErrorTypes, never> - -export interface ServerFormState { +export interface ServerFormState { values: TFormData | undefined validationResults: Array<{ validatorIndex: number - result: FormValidateResultFromErrorTypes< - TFormData, - ToFormErrorTypes> - > + result: FormValidationResult output: unknown | null hasOutput?: boolean }> submissionAttempts: number } + +type SkipServerTrigger = TTrigger extends ServerSideEvents + ? false + : TTrigger extends { trigger: ServerSideEvents; when: infer TWhen } + ? TWhen extends true + ? false + : true + : never + +type ServerOutputValidators> = { + [K in keyof TValidators]: TValidators[K] extends { + triggers?: ReadonlyArray + } + ? [SkipServerTrigger] extends [never] + ? undefined + : Omit & { + skipOnSubmit: SkipServerTrigger + } + : undefined +} + +type ServerValidatorOutputs = unknown extends TValidators + ? Array + : TValidators extends ReadonlyArray + ? GetValidatorOutputs> + : [] + +export interface ServerValidateSuccess { + success: true + values: TFormData + validatorOutputs: ServerValidatorOutputs +} + +export interface ServerValidateFailure { + success: false + serverState: ServerFormState +} + +export type ServerValidateResult = + | ServerValidateSuccess + | ServerValidateFailure diff --git a/packages/form-core/src/FormApi/formState.lib.ts b/packages/form-core/src/FormApi/formState.lib.ts index 59b3fb861f..6b6b40d952 100644 --- a/packages/form-core/src/FormApi/formState.lib.ts +++ b/packages/form-core/src/FormApi/formState.lib.ts @@ -4,17 +4,12 @@ import { getValidationSourceErrors, } from '../validation' import type { FormState } from './FormApi.public' -import type { InternalFormApi } from './FormApi.lib' +import type { AnyInternalFormApi, InternalFormApi } from './FormApi.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' -import type { - FormErrors, - FormValidators, - ToFormErrorTypes, - ValidationIssue, -} from '../validation.public' import type { Atom } from '@tanstack/store' import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' import type { ValidationSourceErrorMap } from '../validation' +import type { ValidationIssue } from '../validation/state.public' export interface FormErrorMeta { validationSourceErrors: ValidationSourceErrorMap | null @@ -81,8 +76,8 @@ const eagerFormStateKeys = [ 'submissionAttempts', ] as const -function getFormErrors( - form: InternalFormApi, +function getFormErrors( + form: InternalFormApi, ): Array { const baseFormErrors = form._atoms.meta.formErrors.get() let formErrors = formErrorsCache.get(baseFormErrors) @@ -214,20 +209,12 @@ export function clearFormValidationSourceErrorsFromEvent({ function getFormStateValue< TFormData, - TFormValidators extends FormValidators, - TSubmitReturn, - TKey extends keyof FormState< - TFormData, - ToFormErrorTypes - >, + TKey extends keyof FormState, >( - form: InternalFormApi, + form: InternalFormApi, key: TKey, overrides: FormStateOverrides = {}, -): FormState< - TFormData, - ToFormErrorTypes ->[TKey] { +): FormState[TKey] { switch (key) { case 'values': return form._atoms.values.get() as never @@ -243,9 +230,7 @@ function getFormStateValue< return (overrides.isDefaultValue?.() ?? form._getIsDefaultValue()) as never case 'errors': - return getFormErrors(form) as FormErrors< - ToFormErrorTypes - > as never + return getFormErrors(form) as never case 'isValid': return (overrides.isValid?.() ?? (getFormErrors(form).length === 0 && @@ -275,18 +260,11 @@ function getFormStateValue< } } -export function getFormStateSnapshot< - TFormData, - TFormValidators extends FormValidators, - TSubmitReturn, ->( - form: InternalFormApi, +export function getFormStateSnapshot( + form: InternalFormApi, overrides: FormStateOverrides = {}, -): FormState> { - const result = {} as FormState< - TFormData, - ToFormErrorTypes - > +): FormState { + const result = {} as FormState for (const key of formStateKeys) { if (key === 'isDefaultValue') { Object.defineProperty(result, key, { @@ -323,7 +301,7 @@ export function compareFormStateSnapshots( } export function createFormStateProxy( - form: InternalFormApi, + form: AnyInternalFormApi, overrides: FormStateOverrides = {}, ): FormState { return new Proxy({} as FormState, { diff --git a/packages/form-core/src/FormApi/formSubmit.public.ts b/packages/form-core/src/FormApi/formSubmit.public.ts index 58d0651875..faf742f090 100644 --- a/packages/form-core/src/FormApi/formSubmit.public.ts +++ b/packages/form-core/src/FormApi/formSubmit.public.ts @@ -3,56 +3,16 @@ import type { PARSED_ISSUES } from '../validation/parseIssues.lib' import type { CreateErrorMapFn, CreatedErrorMap, - FormValidationFailure, ParseIssuesFn, ScopedErrorTypes, - ToFormErrorTypes, + ValidationFailure, ValidationFailureItem, + ValidationOutput, } from '../validation/state.public' -import type { VALIDATION_OUTPUT } from '../validationOutput.lib' -import type { - CreateValidationErrorFn, - FormApi, - OnSubmitError, -} from './FormApi.public' +import type { FormApi } from './FormApi.public' import type { ParseValidatorCondition } from '../validation/formValidators.public' import type { Awaitable } from '../types.public' - -/** - * The result of `createOutput`. Return it from a validator to store its data - * during submission. - * - * @typeParam TOutput - Library-managed. Do not specify explicitly. - * - * @example - * ```ts - * formOptions({ - * defaultValues: { name: '' }, - * validators: [ - * { - * run: ({ createOutput, value }) => { - * return createOutput({ displayName: value.name.trim() }) - * }, - * }, - * { - * // Schemas pass their output data by default. - * run: mySchema, - * }, - * ], - * onSubmit: ({ validatorOutputs }) => { - * // Outputs follow the same order as the validators array. - * const [customData, schemaOutput] = validatorOutputs - * console.log(customData.displayName) - * }, - * }) - * ``` - */ -export interface ValidationOutput { - /** - * The data supplied to `createOutput`. - */ - readonly [VALIDATION_OUTPUT]: TOutput -} +import type { submitErrorSymbol } from './handleSubmit.lib' type UnwrapValidatorFunction = TOutput extends { readonly [PARSED_ISSUES]: true @@ -97,6 +57,14 @@ export type GetValidatorOutputs> = { [K in keyof TValidators]: GetValidatorOutput } +export type OnSubmitError = { + readonly [submitErrorSymbol]: T +} + +export type CreateValidationErrorFn = ( + error: TError, +) => OnSubmitError + type SubmitErrorMap = CreatedErrorMap< TFormData, ValidationFailureItem @@ -137,7 +105,7 @@ export interface FormSubmitContext< /** The form values for this submission, without schema transformations applied. */ value: TFormData /** The form instance handling this submission. */ - formApi: FormApi>> + formApi: FormApi> /** * The validator outputs from this submission. * @@ -180,7 +148,7 @@ export interface FormSubmitContext< * from a submission handler. Creating the error alone does not update form or * field validation state. */ - createValidationError: CreateValidationErrorFn + createValidationError: CreateValidationErrorFn /** * Builds a mutable submission error map from optional initial errors. Return * the map to apply its form and field errors. An empty map indicates success. @@ -224,7 +192,7 @@ export interface FormSubmitInvalidContext< /** The form values for the failed submission, without schema transformations applied. */ value: TFormData /** The form instance handling the failed submission. */ - formApi: FormApi> + formApi: FormApi } /** diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index ad24cab6c1..ba38005f53 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -5,60 +5,56 @@ import { parseValidationResult, } from '../validation' import { createErrorMapFactory } from '../createErrorMap.lib' -import type { StandardSchemaV1Issue } from '../standardSchema.public' +import { parseIssuesFactory } from '../validation/parseIssues.lib' import type { InternalFormApi } from './FormApi.lib' +import type { ValidationErrorInput } from '../validation.public' import type { - FormValidateResult, - FormValidationError, - ValidationErrorInput, - ValidationErrorMap, -} from '../validation.public' + FormValidationFailure, + FormValidationResult, +} from '../validation/state.public' import type { - CreateSubmitErrorMapFn, CreateValidationErrorFn, + FormSubmitContext, OnSubmitError, -} from './FormApi.public' +} from './formSubmit.public' export const submitErrorSymbol = Symbol('Submit error') function isSubmitError( value: unknown, -): value is OnSubmitError> { +): value is OnSubmitError> { return ( typeof value === 'object' && value !== null && submitErrorSymbol in value ) } -const createValidationError: CreateValidationErrorFn = < - TError extends FormValidationError, +const createValidationError: CreateValidationErrorFn = < + TError extends FormValidationFailure, >( error: TError, ): OnSubmitError => { return createSubmitError(error) } -function createSubmitError>( +function createSubmitError>( error: TError, ): OnSubmitError { return { [submitErrorSymbol]: error } } -function createSubmitErrorMapFactory( - value: TFormData, -): CreateSubmitErrorMapFn { - const createErrorMap = createErrorMapFactory(value) - - return ((initial) => { - const map = createErrorMap(initial as never) - // Read the map itself so edits made after creation are included. - Object.defineProperty(map, submitErrorSymbol, { value: map }) - return map - }) as CreateSubmitErrorMapFn +function withSubmitError unknown>( + callback: TFunction, +): any { + return (...args: any[]) => { + const obj = callback(...args) + Object.defineProperty(obj, submitErrorSymbol, { value: obj }) + return obj + } } export async function runSubmissionProcess( - form: InternalFormApi, -): Promise>> { + form: InternalFormApi, +): Promise>> { const submitResetVersion = form._atoms.resetVersion.get() const hasResettedFormDuringSubmit = () => form._atoms.resetVersion.get() !== submitResetVersion @@ -71,7 +67,7 @@ export async function runSubmissionProcess( const submissionData = { hasFailed: false, submitError: - null satisfies FormValidateResult as FormValidateResult, + null satisfies FormValidationResult as FormValidationResult, } const fields = form._fieldRootNode._touchAllFieldsAndCollectSubmitValidators() @@ -140,7 +136,7 @@ export async function runSubmissionProcess( const finishInvalidSubmission = async ( value: TFormData, - ): Promise>> => { + ): Promise>> => { try { await form._options.onSubmitInvalid?.({ value, @@ -155,7 +151,7 @@ export async function runSubmissionProcess( } // TODO ew. Resolve this differently, this is really bad. - return errorResults.flatMap>((error) => { + return errorResults.flatMap>((error) => { const { self, subfields } = parseValidationResult(error) if (isValidationErrorMap(error)) { return [{ ...error, form: self ?? undefined, fields: subfields ?? {} }] @@ -177,8 +173,9 @@ export async function runSubmissionProcess( validatorOutputs, value, createValidationError, - createErrorMap: createSubmitErrorMapFactory(value), - }) + createErrorMap: withSubmitError(createErrorMapFactory(value)), + parseIssues: withSubmitError(parseIssuesFactory(value)), + } satisfies FormSubmitContext as any) if (hasResettedFormDuringSubmit()) { return [] diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index 9d3d7b5d64..c30c24b567 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -41,19 +41,21 @@ import type { } from './FormGroupApi.public' import type { ConfigurableValidationTrigger, - FormErrorTypes, - FormErrors, FormGroupValidateResult, - FormGroupValidator, FormGroupValidators, ToFormGroupErrorTypes, - ValidationIssue, } from '../validation.public' import type { ReadonlyAtom } from '@tanstack/store' import type { InternalValidatorInstance, InternalValidatorInstances, } from '../ValidatorInstance.lib' +import type { + ScopedErrorTypes, + ToFormErrorTypes, + ToScopedErrorTypes, + ValidationIssue, +} from '../validation/state.public' interface FormGroupValidationOutcome { errors: Array> @@ -68,7 +70,7 @@ export type AnyInternalFormGroupApi = InternalFormGroupApi< any > export type InternalGroupValidatorInstance = InternalValidatorInstance< - FormGroupValidator, + 'group', AnyInternalFormGroupApi, AnyInternalFieldApi > @@ -78,16 +80,15 @@ export class InternalFormGroupApi< TGroupName extends DeepKeys, TGroupValue extends DeepValue, const TGroupValidators extends FormGroupValidators, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > implements FormGroupApi< TFormData, TGroupName, TGroupValue, ToFormGroupErrorTypes, - TFormErrorTypes + TFormError > { - readonly form: FormApi & - InternalFormApi + readonly form: FormApi & InternalFormApi /** The trie node occupied by this form group. */ _groupField: AnyInternalFieldApi _options: FormGroupOptions< @@ -95,11 +96,11 @@ export class InternalFormGroupApi< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError > /** Stable runtime instances correlated with `_options.validators` by slot. */ _validatorInstances: InternalValidatorInstances< - TGroupValidators[number], + 'group', AnyInternalFormGroupApi, AnyInternalFieldApi > @@ -132,7 +133,7 @@ export class InternalFormGroupApi< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, ) { this.form = options.form as never @@ -148,7 +149,7 @@ export class InternalFormGroupApi< ) this._groupField._setFormGroup(this) this._validatorInstances = reconcileValidatorInstances< - TGroupValidators[number], + 'group', AnyInternalFormGroupApi, AnyInternalFieldApi >({ @@ -181,7 +182,9 @@ export class InternalFormGroupApi< groupMetaMarkers, ) const groupErrorMeta = groupBaseMeta._validationSourceErrors - let groupErrors: FormErrors> + let groupErrors: Array< + ToFormGroupErrorTypes['selfError'] + > if ( prev && @@ -265,7 +268,7 @@ export class InternalFormGroupApi< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, ) => { const previousValidators = this._options.validators @@ -274,7 +277,7 @@ export class InternalFormGroupApi< this.form._defaultOptions?.formGroup, ) this._validatorInstances = reconcileValidatorInstances< - TGroupValidators[number], + 'group', AnyInternalFormGroupApi, AnyInternalFieldApi >({ diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts index 5ec9acd2d7..2ce76ab83f 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts @@ -1,9 +1,8 @@ import type { ReadonlyAtom } from '@tanstack/store' import type { FormApi } from '../FormApi/FormApi.public' +import type { ScopedErrorTypes } from '../validation/state.public' +import type { ClientSideEvents } from '../events.public' import type { - ConfigurableValidationTrigger, - FormErrorTypes, - FormErrors, FormGroupValidateResult, FormGroupValidators, ToFormGroupErrorTypes, @@ -28,20 +27,20 @@ export interface FormGroupSubmitContext< in out TGroupName, in out TGroupValue, out TOutputs, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, > { /** The group values for this submission. */ value: TGroupValue /** The parent form API handling this submission. */ - formApi: FormApi + formApi: FormApi /** The group API handling this submission. */ groupApi: FormGroupApi< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes + TGroupError, + TFormError > /** * Schema outputs and `createOutput` payloads from this group submission, @@ -81,20 +80,20 @@ export interface FormGroupSubmitInvalidContext< in out TFormData, in out TGroupName, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, > { /** The group values for the failed submission. */ value: TGroupValue /** The parent form API handling the failed submission. */ - formApi: FormApi + formApi: FormApi /** The group API handling the failed submission. */ groupApi: FormGroupApi< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes + TGroupError, + TFormError > } @@ -103,7 +102,7 @@ export type FormGroupSubmitFn< in out TGroupName, in out TGroupValue, in out TGroupValidators extends FormGroupValidators, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, > = ( context: FormGroupSubmitContext< TFormData, @@ -111,7 +110,7 @@ export type FormGroupSubmitFn< TGroupValue, ToFormGroupOutputs>, ToFormGroupErrorTypes>, - TFormErrorTypes + TFormError >, ) => void | Promise @@ -120,9 +119,9 @@ export interface FormGroupOptions< in out TGroupName, in out TGroupValue, in out TGroupValidators extends FormGroupValidators, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, > { - form: FormApi + form: FormApi name: TGroupName validators?: TGroupValidators /** @@ -144,7 +143,7 @@ export interface FormGroupOptions< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError > /** * Called when group validation fails or validation or submission throws. The @@ -167,19 +166,19 @@ export interface FormGroupOptions< TFormData, TGroupName, TGroupValue, - ToFormGroupErrorTypes>, - TFormErrorTypes + NoInfer>, + TFormError >, ) => void | Promise } export interface FormGroupState< in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, > { values: TGroupValue meta: unknown - errors: FormErrors + errors: Array isTouched: boolean isDirty: boolean isPristine: boolean @@ -196,17 +195,17 @@ export interface FormGroupApi< in out TFormData, in out TGroupName, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, > { - readonly form: FormApi + readonly form: FormApi readonly name: TGroupName - atom: ReadonlyAtom> - readonly state: FormGroupState + atom: ReadonlyAtom> + readonly state: FormGroupState readonly value: TGroupValue validate: ( - signal: ConfigurableValidationTrigger | 'submit', + signal: ClientSideEvents, ) => Promise>> handleSubmit: () => Promise>> reset: () => void diff --git a/packages/form-core/src/ListenerInstance.lib.ts b/packages/form-core/src/ListenerInstance.lib.ts index df06dea1c9..bf92ab0293 100644 --- a/packages/form-core/src/ListenerInstance.lib.ts +++ b/packages/form-core/src/ListenerInstance.lib.ts @@ -1,5 +1,6 @@ import { LiteDebouncer } from '@tanstack/pacer-lite' -import type { AnyFieldListener, AnyFormListener } from './listeners.public' +import type { AnyFieldListener } from './listeners.public' +import type { AnyFormListener } from './FormApi/formListeners.public' export type InternalListenerDefinition = AnyFormListener | AnyFieldListener diff --git a/packages/form-core/src/ValidatorInstance.lib.ts b/packages/form-core/src/ValidatorInstance.lib.ts index a64b546b03..a7f6ff9bbc 100644 --- a/packages/form-core/src/ValidatorInstance.lib.ts +++ b/packages/form-core/src/ValidatorInstance.lib.ts @@ -3,13 +3,18 @@ import { InternalValidationSourceInstance } from './ValidationSourceInstance.lib import type { StandardSchemaV1 } from './standardSchema.public' import type { BaseValidator, + FieldValidator, + FormGroupValidator, ValidatorFn, ValidatorScope, } from './validation.public' +import type { FormValidator } from './validation/formValidators.public' -export type InternalValidatorDefinition = BaseValidator< - StandardSchemaV1 | ValidatorFn -> +type GetDefinition = { + form: FormValidator + group: FormGroupValidator + field: FieldValidator +}[TScope] export type ValidatorInstanceDebouncedFn = (...args: Array) => any @@ -19,25 +24,25 @@ export type AnyInternalValidatorInstance< > = InternalValidatorInstance export interface InternalValidatorInstanceOptions< - out TDefinition extends InternalValidatorDefinition, out TOwner, + out TScope extends ValidatorScope, > { - definition: TDefinition owner: TOwner - scope: ValidatorScope + scope: TScope + definition: any index?: number } /** Stable runtime instances correlated with validator definitions by slot. */ export type InternalValidatorInstances< - TDefinition extends InternalValidatorDefinition, + TScope extends ValidatorScope, TOwner, TErrorTarget = unknown, TWatchedField = unknown, TOutput = unknown, > = Array< InternalValidatorInstance< - TDefinition, + TScope, TOwner, TErrorTarget, TWatchedField, @@ -46,24 +51,24 @@ export type InternalValidatorInstances< > | null export interface ReconcileValidatorInstancesOptions< - TDefinition extends InternalValidatorDefinition, + TScope extends ValidatorScope, TOwner, TErrorTarget = unknown, TWatchedField = unknown, TOutput = unknown, > { /** The latest validator definitions installed on the owner. */ - definitions: ReadonlyArray | null | undefined + definitions: ReadonlyArray> | null | undefined /** * The definitions installed before an update. * * Omit during initialization. Pass `null` when updating an owner that * previously had no validators. */ - previousDefinitions?: ReadonlyArray | null + previousDefinitions?: ReadonlyArray> | null /** The owner's currently installed instances, if it has any. */ instances: InternalValidatorInstances< - TDefinition, + TScope, TOwner, TErrorTarget, TWatchedField, @@ -72,11 +77,11 @@ export interface ReconcileValidatorInstancesOptions< /** The validation boundary that owns every reconciled instance. */ owner: TOwner /** The form, group, or field scope shared by the reconciled instances. */ - scope: ValidatorScope + scope: TScope /** Cleans owner-specific state before an instance is permanently disposed. */ onBeforeDispose?: ( instance: InternalValidatorInstance< - TDefinition, + TScope, TOwner, TErrorTarget, TWatchedField, @@ -89,7 +94,7 @@ export interface ReconcileValidatorInstancesOptions< * Runtime state owned by one installed validator occurrence. */ export class InternalValidatorInstance< - TDefinition extends InternalValidatorDefinition, + TScope extends ValidatorScope, TOwner, TErrorTarget = unknown, TWatchedField = unknown, @@ -98,7 +103,7 @@ export class InternalValidatorInstance< ValidatorInstanceDebouncedFn, > extends InternalValidationSourceInstance { /** The current validator definition associated with this stable instance. */ - definition: TDefinition + definition: GetDefinition /** The controller for the active execution, or `null` when none is active. */ abortController: AbortController | null = null /** The lazily created debouncer for this validator's pending execution. */ @@ -133,7 +138,7 @@ export class InternalValidatorInstance< owner, scope, index, - }: InternalValidatorInstanceOptions) { + }: InternalValidatorInstanceOptions) { super({ owner, scope, index }) this.definition = definition } @@ -144,7 +149,7 @@ export class InternalValidatorInstance< * Every update advances `revision`, including assignment of the same object. * The operation is ignored after disposal. */ - updateDefinition(definition: TDefinition): void { + updateDefinition(definition: GetDefinition): void { if (this.disposed) return this.definition = definition @@ -325,7 +330,7 @@ export class InternalValidatorInstance< * Updates with a different definition count emit the shared invariant warning. */ export function reconcileValidatorInstances< - TDefinition extends InternalValidatorDefinition, + TScope extends ValidatorScope, TOwner, TErrorTarget = unknown, TWatchedField = unknown, @@ -338,13 +343,13 @@ export function reconcileValidatorInstances< scope, onBeforeDispose, }: ReconcileValidatorInstancesOptions< - TDefinition, + TScope, TOwner, TErrorTarget, TWatchedField, TOutput >): InternalValidatorInstances< - TDefinition, + TScope, TOwner, TErrorTarget, TWatchedField, @@ -375,7 +380,7 @@ export function reconcileValidatorInstances< instance.updateDefinition(definition) } else { nextInstances[index] = new InternalValidatorInstance< - TDefinition, + TScope, TOwner, TErrorTarget, TWatchedField, diff --git a/packages/form-core/src/createErrorMap.lib.ts b/packages/form-core/src/createErrorMap.lib.ts index 1326592cce..60ac153fb2 100644 --- a/packages/form-core/src/createErrorMap.lib.ts +++ b/packages/form-core/src/createErrorMap.lib.ts @@ -1,6 +1,9 @@ import { parseStandardSchemaIssues } from './standardSchema.lib' import { isReadonlyArray } from './utils.lib' -import type { CreateErrorMapFn } from './validation.public' +import type { + CreateErrorMapFn, + ValidationErrorMap, +} from './validation/state.public' type TupleNarrower = (arg: any) => arg is ReadonlyArray @@ -14,7 +17,7 @@ export function createErrorMapFactory( if (isReadonlyArray(initial)) { return parseStandardSchemaIssues(initial, value, 'form') } - const map = initial ?? {} + const map = initial ?? ({} as ValidationErrorMap) map.fields ??= {} return map }) as CreateErrorMapFn diff --git a/packages/form-core/src/defaultOptions.public.ts b/packages/form-core/src/defaultOptions.public.ts index 9b15646deb..8896d89d23 100644 --- a/packages/form-core/src/defaultOptions.public.ts +++ b/packages/form-core/src/defaultOptions.public.ts @@ -1,12 +1,8 @@ import type { FieldApiOptions } from './FieldApi/FieldApi.public' -import type { FormOptions } from './FormApi/FormApi.public' +import type { AnyFormOptions } from './FormApi/formOptions.public' import type { FormGroupOptions } from './FormGroupApi/FormGroupApi.public' -import type { - FieldValidators, - FormErrorTypes, - FormGroupValidators, - FormValidators, -} from './validation.public' +import type { FieldValidators, FormGroupValidators } from './validation.public' +import type { ScopedErrorTypes } from './validation/state.public' /** * Determines how a supplied usage-site listener array combines with default @@ -56,7 +52,7 @@ interface DefaultListenersMergeOptions { * ``` */ export type DefaultFormOptions = Pick< - FormOptions, unknown, unknown>, + AnyFormOptions, 'errorVisibility' | 'listeners' | 'onSubmitInvalid' > & DefaultListenersMergeOptions @@ -87,7 +83,7 @@ export type DefaultFieldOptions = Pick< FieldValidators, never, unknown, - FormErrorTypes + ScopedErrorTypes >, 'errorVisibility' | 'errorBoundary' | 'listeners' > & @@ -117,7 +113,7 @@ export type DefaultFormGroupOptions = Pick< string, unknown, FormGroupValidators, - FormErrorTypes + ScopedErrorTypes >, 'onSubmitInvalid' > diff --git a/packages/form-core/src/index.ts b/packages/form-core/src/index.ts index 62df96180e..6ba2c34480 100644 --- a/packages/form-core/src/index.ts +++ b/packages/form-core/src/index.ts @@ -2,13 +2,21 @@ export * from './FieldApi/FieldApi.public' export * from './FormApi/FormApi.public' export * from './FormApi/FormApiArrayMethods.types.public' export * from './FormApi/FormApiFieldMethods.types.public' +export * from './FormApi/errorVisibility.public' +export * from './FormApi/formListeners.public' +export * from './FormApi/formOptions.public' +export * from './FormApi/formServerState.public' +export * from './FormApi/formSubmit.public' export * from './FormGroupApi/FormGroupApi.public' export * from './FieldGroup/fieldGroupTypes.public' export * from './types.public' export * from './validation.public' +export * from './validation/formValidators.public' +export * from './validation/state.public' +export * from './new-validation.public' export * from './standardSchema.public' export * from './listeners.public' +export * from './events.public' export * from './utils.public' export * from './deep-keys.public' -export * from './ssr.public' export * from './defaultOptions.public' diff --git a/packages/form-core/src/listeners.lib.ts b/packages/form-core/src/listeners.lib.ts index 251b87220c..d1dd1365f7 100644 --- a/packages/form-core/src/listeners.lib.ts +++ b/packages/form-core/src/listeners.lib.ts @@ -1,12 +1,14 @@ import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' -import type { InternalFormApi } from './FormApi/FormApi.lib' +import type { AnyInternalFormApi } from './FormApi/FormApi.lib' +import type { + AnyFormListener, + FormListenerContext, +} from './FormApi/formListeners.public' import type { AnyInternalListenerInstance } from './ListenerInstance.lib' import type { AnyFieldListener, - AnyFormListener, FieldListenerContext, FieldListenerTriggers, - FormListenerContext, Listener, ListenerDebounceFn, ListenerPredicateFn, @@ -18,13 +20,13 @@ type ListenerEvent = FieldListenerTriggers type FormInputContext = { event: ListenerEvent triggerFieldApi?: AnyInternalFieldApi - formApi: InternalFormApi + formApi: AnyInternalFormApi } type FieldInputContext = { event: ListenerEvent fieldApi: AnyInternalFieldApi - formApi: InternalFormApi + formApi: AnyInternalFormApi } type InputContext = FormInputContext | FieldInputContext diff --git a/packages/form-core/src/listeners.public.ts b/packages/form-core/src/listeners.public.ts index ba1c98fd52..6ca2ba027f 100644 --- a/packages/form-core/src/listeners.public.ts +++ b/packages/form-core/src/listeners.public.ts @@ -1,20 +1,8 @@ import type { DeepKeys } from './deep-keys.public' import type { FormApi } from './FormApi/FormApi.public' import type { AnyFieldApi, FieldApi } from './FieldApi/FieldApi.public' -import type { FormErrorTypes, ValidationTrigger } from './validation.public' - -/** - * Events that can invoke a listener configured on a form. - * - * - `'change'`: A field value changed. The listener receives the updated form - * values. - * - `'blur'`: A field was marked as blurred. - * - `'submit'`: A submission attempt started. The listener runs before - * submission validation. - * - `'mount'`: The component using the form was mounted. - * - `'reset'`: The form finished resetting its values and state. - */ -export type FormListenerTriggers = ValidationTrigger | 'mount' | 'reset' +import type { ScopedErrorTypes } from './validation/state.public' +import type { FormListenerTriggers } from './FormApi/formListeners.public' /** * Events that can invoke a listener configured on a field. @@ -191,142 +179,6 @@ export interface Listener< triggers: Array> } -/** - * Context passed to a form listener. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export interface FormListenerContext< - in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, -> { - /** - * The field that caused the event when available. - * - * Some form events are initiated by the form itself, so no field causes - * them. `triggerFieldApi` is always `undefined` for: - * - * - `'mount'`, when the component using the form is mounted. - * - `'reset'`, when the form is reset. - * - `'submit'`, when a submission attempt starts. - * - */ - triggerFieldApi?: AnyFieldApi - /** The form that owns the listener. */ - formApi: FormApi - /** The form values captured when the event occurred. */ - value: TFormData -} - -/** - * A callback invoked when a form listener runs. - * - * The return value is ignored. A returned promise is not awaited, and a - * rejected promise is reported to the console. - * - * @example - * ```ts - * run: ({ value }) => { - * saveDraft(value) - * }, - * ``` - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export type FormListenerFn< - in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, -> = (context: FormListenerContext) => void - -/** A form listener with type-erased form data and error types. */ -export type AnyFormListener = FormListener - -/** - * A listener configured on a form. - * - * Form listeners can observe field changes and blurs as well as form - * submission, mounting, and resetting. - * - * @example - * ```ts - * formOptions({ - * defaultValues: { displayName: '' }, - * listeners: [ - * { - * triggers: [ - * { - * trigger: 'change', - * when: ({ value }) => value.displayName.length > 0, - * }, - * ], - * triggerDebounceMs: 200, - * run: ({ value }) => { - * saveDraft(value) - * }, - * }, - * ], - * }) - * ``` - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export interface FormListener< - in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, -> extends Listener { - /** - * Called when an enabled form trigger occurs. - * - * The return value is ignored. A returned promise is not awaited, and a - * rejected promise is reported to the console. - * - * @example - * ```ts - * run: ({ value }) => { - * saveDraft(value) - * }, - * ``` - */ - run: FormListenerFn -} - -/** - * Listener configurations evaluated in array order for each form event. - * - * A debounced listener may execute after later, non-debounced listeners. - * - * @example - * ```ts - * formOptions({ - * defaultValues: { displayName: '' }, - * listeners: [ - * { - * triggers: [ - * { - * trigger: 'change', - * when: ({ value }) => value.displayName.length > 0, - * }, - * ], - * triggerDebounceMs: 200, - * run: ({ value }) => { - * saveDraft(value) - * }, - * }, - * ], - * }) - * ``` - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export type FormListeners< - TFormData, - TFormErrorTypes extends FormErrorTypes, -> = Array> - /** * Context passed to a field listener. * @@ -334,14 +186,14 @@ export type FormListeners< * @typeParam TFieldValue - Library-managed. Do not specify explicitly. * @typeParam TFieldError - Library-managed. Do not specify explicitly. * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. + * @typeParam TFormError - Library-managed. Do not specify explicitly. */ export interface FieldListenerContext< out TFieldName, in out TFieldValue, in out TFieldError, in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, > { /** The listening field's value captured when the event occurred. */ value: TFieldValue @@ -356,10 +208,10 @@ export interface FieldListenerContext< TFieldValue, TFieldError, TFormData, - TFormErrorTypes + TFormError > /** The form that owns the field. */ - formApi: FormApi + formApi: FormApi } /** @@ -382,21 +234,21 @@ export interface FieldListenerContext< * @typeParam TFieldValue - Library-managed. Do not specify explicitly. * @typeParam TFieldError - Library-managed. Do not specify explicitly. * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. + * @typeParam TFormError - Library-managed. Do not specify explicitly. */ export type FieldListenerFn< in TFieldName, in out TFieldValue, in out TFieldError, in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, > = ( context: FieldListenerContext< TFieldName, TFieldValue, TFieldError, TFormData, - TFormErrorTypes + TFormError >, ) => void @@ -432,7 +284,7 @@ export type AnyFieldListener = FieldListener * @typeParam TFieldValue - Library-managed. Do not specify explicitly. * @typeParam TFieldError - Library-managed. Do not specify explicitly. * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. + * @typeParam TFormError - Library-managed. Do not specify explicitly. */ export interface FieldListener< in out TFieldData, @@ -440,7 +292,7 @@ export interface FieldListener< in out TFieldValue, in out TFieldError, in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, > extends Listener { /** * Called when an enabled field trigger occurs. @@ -463,7 +315,7 @@ export interface FieldListener< TFieldValue, TFieldError, TFormData, - TFormErrorTypes + TFormError > /** * Other fields whose matching events should also invoke this listener. @@ -509,7 +361,7 @@ export interface FieldListener< * @typeParam TFieldValue - Library-managed. Do not specify explicitly. * @typeParam TFieldError - Library-managed. Do not specify explicitly. * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. + * @typeParam TFormError - Library-managed. Do not specify explicitly. */ export type FieldListeners< TFieldData, @@ -517,7 +369,7 @@ export type FieldListeners< TFieldValue, TFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > = Array< FieldListener< TFieldData, @@ -525,6 +377,6 @@ export type FieldListeners< TFieldValue, TFieldError, TFormData, - TFormErrorTypes + TFormError > > diff --git a/packages/form-core/src/new-validation.public.ts b/packages/form-core/src/new-validation.public.ts new file mode 100644 index 0000000000..ff1fa52e36 --- /dev/null +++ b/packages/form-core/src/new-validation.public.ts @@ -0,0 +1,20 @@ +import z from 'zod' +import { formOptions } from './FormApi/formOptions.public' + +// TODO remove + +const test = false as boolean + +const schema = z.object({ name: z.string() }).transform((d) => d.name.length) + +const opts = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: () => {}, + }, + ], + onSubmit: ({ validatorOutputs }) => { + const [schemaOutput] = validatorOutputs + }, +}) diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index 456fcddc34..f4259f4e55 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -7,24 +7,15 @@ import { runValidatorPipeline } from './validation' import { createErrorMapFactory } from './createErrorMap.lib' import { devtools } from './devtoolsBridge.lib' import { reconcileValidatorInstances } from './ValidatorInstance.lib' -import type { FormOptions } from './FormApi/FormApi.public' import type { FormErrorMeta } from './FormApi/formState.lib' -import type { InternalFormApi } from './FormApi/FormApi.lib' +import type { AnyInternalFormApi, InternalFormApi } from './FormApi/FormApi.lib' import type { AnyInternalFieldApi } from './FieldApi/FieldApi.lib' +import type { FormValidationResult } from './validation/state.public' import type { - FormValidateResultFromErrorTypes, - FormValidators, - ToServerFormErrorTypes, -} from './validation.public' -import type { ServerFormState, ServerValidateResult } from './ssr.public' - -type ServerFormValidateResult< - TFormData, - TFormValidators extends FormValidators, -> = FormValidateResultFromErrorTypes< - TFormData, - ToServerFormErrorTypes -> + ServerFormState, + ServerValidateResult, +} from './FormApi/formServerState.public' +import type { FormOptions } from './FormApi/formOptions.public' function createInitialFormErrorMeta(): FormErrorMeta { return { @@ -32,7 +23,7 @@ function createInitialFormErrorMeta(): FormErrorMeta { } } -function resetFieldMetaForServerState(form: InternalFormApi) { +function resetFieldMetaForServerState(form: AnyInternalFormApi) { visitAllFormFields(form._fieldRootNode, (field) => { field._defaultValueCache = null @@ -48,8 +39,8 @@ function resetFieldMetaForServerState(form: InternalFormApi) { } function resetToServerState( - form: InternalFormApi, - serverState: ServerFormState, + form: InternalFormApi, + serverState: ServerFormState, defaultValues: TFormData, ): void { const values = serverState.values ?? defaultValues @@ -97,8 +88,8 @@ function resetToServerState( } export function applyServerState( - form: InternalFormApi, - serverState: ServerFormState | null, + form: InternalFormApi, + serverState: ServerFormState | null, defaultValues: TFormData, ): void { if (serverState === form._lastServerState) return @@ -115,12 +106,19 @@ export function applyServerState( export async function validateServerValues< TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( - options: FormOptions, + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, values: TFormData, -): Promise> { +): Promise> { const pipeline = options.validators if (!pipeline || pipeline.length === 0) { @@ -140,7 +138,7 @@ export async function validateServerValues< try { const pipelineResult = await runValidatorPipeline< - ServerFormValidateResult + FormValidationResult >({ pipeline: validatorInstances ?? [], context: { diff --git a/packages/form-core/src/ssr.public.ts b/packages/form-core/src/ssr.public.ts deleted file mode 100644 index 14f04d4cb2..0000000000 --- a/packages/form-core/src/ssr.public.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { FormOptions } from './FormApi/FormApi.public' -import type { - FormValidateResultFromErrorTypes, - FormValidators, - ToServerFormErrorTypes, - ToServerFormOutputs, -} from './validation.public' - -export const initialServerFormState: ServerFormState = { - values: undefined, - validationResults: [], - submissionAttempts: 0, -} - -type ServerValidateHelperResult< - TFramework extends ServerValidateFrameworkPlugin, -> = Omit & { - initialServerFormState: ServerFormState -} - -export function serverValidateHelper< - const TFramework extends ServerValidateFrameworkPlugin, ->(options: { framework: TFramework }): ServerValidateHelperResult { - const { id: _unused, ...framework } = options.framework - - return { - initialServerFormState, - ...framework, - } -} - -interface ServerStateValidationResult { - validatorIndex: number - result: TResult - output: unknown | null - hasOutput?: boolean -} - -interface ServerFormStateByResult { - values: TFormData | undefined - validationResults: Array> - submissionAttempts: number -} - -export type ServerFormState< - TFormData, - TFormValidators extends FormValidators, -> = ServerFormStateByResult< - TFormData, - FormValidateResultFromErrorTypes< - TFormData, - ToServerFormErrorTypes - > -> - -export interface ServerValidateSuccess< - in out TFormData, - in out TFormValidators extends FormValidators, -> { - success: true - values: TFormData - /** - * Schema outputs and `createOutput` payloads from this server validation, - * ordered by validator index. Validators without a server trigger or output - * contribute `undefined`. These are separate from client submission outputs. - */ - validatorOutputs: ToServerFormOutputs -} - -export interface ServerValidateFailure< - in out TFormData, - in out TFormValidators extends FormValidators, -> { - success: false - serverState: ServerFormState -} - -export type ServerValidateResult< - TFormData, - TFormValidators extends FormValidators, -> = - | ServerValidateSuccess - | ServerValidateFailure - -type ServerValidateFrameworkCreateServerValidate = < - TFormData, - const TFormValidators extends FormValidators, - TSubmitReturn, ->( - formOptions: FormOptions, - pluginOptions?: any, -) => unknown - -export interface ServerValidateFrameworkPlugin< - TCreateServerValidate extends ServerValidateFrameworkCreateServerValidate = - ServerValidateFrameworkCreateServerValidate, -> { - id: `react-form-${string}` - createServerValidate: TCreateServerValidate -} diff --git a/packages/form-core/src/standardSchema.lib.ts b/packages/form-core/src/standardSchema.lib.ts index 0e7e7af1d5..576cd8a54f 100644 --- a/packages/form-core/src/standardSchema.lib.ts +++ b/packages/form-core/src/standardSchema.lib.ts @@ -1,5 +1,6 @@ import { isNil } from './utils.lib' import { createOutput } from './validationOutput.lib' +import { PARSED_ISSUES } from './validation/parseIssues.lib' import type { StandardSchemaV1, StandardSchemaV1Issue, @@ -7,10 +8,12 @@ import type { import type { FieldValidatorFn, FormGroupValidatorFn, - FormValidatorFn, +} from './validation.public' +import type { FormValidatorFn } from './validation/formValidators.public' +import type { ParsedStandardSchemaIssues, ValidationOutput, -} from './validation.public' +} from './validation/state.public' export function prefixSchemaToErrors( issues: ReadonlyArray, @@ -65,7 +68,7 @@ function getPathFromIssue( export function isStandardSchema( object: - | FormValidatorFn + | FormValidatorFn | FormGroupValidatorFn | FieldValidatorFn | StandardSchemaV1, @@ -117,6 +120,7 @@ export function parseStandardSchemaIssues( } return { + [PARSED_ISSUES]: true, form: issues.slice(), fields: prefixSchemaToErrors( issues, diff --git a/packages/form-core/src/utils.lib.ts b/packages/form-core/src/utils.lib.ts index 60f7efa72b..c10f6e5ba7 100644 --- a/packages/form-core/src/utils.lib.ts +++ b/packages/form-core/src/utils.lib.ts @@ -203,7 +203,7 @@ export function callUpdater(updater: Updater, object: T): T { } export function normalizeToArray( - input: null | undefined | ReadonlyOneOrMany, + input: null | undefined | void | ReadonlyOneOrMany, ): ReadonlyArray { if (Array.isArray(input)) return input if (input === null || input === undefined) return [] diff --git a/packages/form-core/src/utils.public.ts b/packages/form-core/src/utils.public.ts index 4782f36873..07a3e54661 100644 --- a/packages/form-core/src/utils.public.ts +++ b/packages/form-core/src/utils.public.ts @@ -1,7 +1,3 @@ -import type { FormOptions } from './FormApi/FormApi.public' -import type { StandardSchemaV1 } from './standardSchema.public' -import type { FormValidators } from './validation.public' - type Primitive = string | number | boolean | bigint | symbol | null | undefined export type BuiltInType = Primitive | Date | RegExp | Function @@ -18,6 +14,19 @@ type EditableObject = { [K in keyof T]?: Editable } +type InferUnionObject< + in out TBase extends object, + in out TIncoming extends object, +> = { + [K in keyof TBase | keyof TIncoming]: K extends keyof TBase + ? K extends keyof TIncoming + ? InferUnion + : TBase[K] + : K extends keyof TIncoming + ? TIncoming[K] | undefined + : never +} + export type InferUnion = [TBase] extends [never] ? TIncoming : TBase extends BuiltInType @@ -33,348 +42,3 @@ export type InferUnion = [TBase] extends [never] ? InferUnionObject : TBase | TIncoming : TBase | TIncoming - -type InferUnionObject< - in out TBase extends object, - in out TIncoming extends object, -> = { - [K in keyof TBase | keyof TIncoming]: K extends keyof TBase - ? K extends keyof TIncoming - ? InferUnion - : TBase[K] - : K extends keyof TIncoming - ? TIncoming[K] | undefined - : never -} - -export type FormValidatorData> = - TFormValidators extends FormValidators ? T : never - -export type NullableSchemaData> = - Editable> - -type StandardSchemaInput> = - TSchema extends StandardSchemaV1 ? TInput : never - -type LooseSchemaFormOptions< - TSchemaInput, - TFormData extends Editable, - TFormValidators extends FormValidators< - NoInfer> - >, - TSubmitReturn, -> = Omit< - FormOptions< - InferUnion, - TFormValidators, - TSubmitReturn, - unknown - >, - 'defaultValues' -> & { - defaultValues: TFormData -} - -/** - * Types strict form options using a separate schema as the source of the form - * data type. - * - * The schema input fixes the form data type before the options are inferred, - * so `defaultValues` and each callback validator's `value` use the exact - * schema input type. - * - * The first argument is used only by TypeScript and is ignored at runtime. - * Include the schema in `validators` as well when it should validate the form. - * Parsed results are available in the corresponding `validatorOutputs` entries - * during submission. - * - * @example - * ```ts - * const profileSchema = z.object({ name: z.string().min(1) }) - * const profileOptions = formOptions.strictSchema(profileSchema, { - * defaultValues: { name: '' }, - * validators: [ - * { triggers: ['change'], run: profileSchema }, - * { - * triggers: ['change'], - * run: ({ value }) => - * value.name.length === 0 ? 'Name is required' : undefined, - * }, - * ], - * }) - * ``` - * - * @param schema - Supplies the form data type without registering a validator. - * @param options - The form options to type against the schema input. - * @returns The original options object, normalized to `FormOptions` with the - * schema input as its form data type. - * @typeParam TComponents - Library-managed. Do not specify explicitly. - * @typeParam TSchema - Library-managed. Do not specify explicitly. - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. - */ -export type FormOptionsStrictSchemaFn = < - const TSchema extends StandardSchemaV1, - const TFormValidators extends FormValidators>, - TSubmitReturn, ->( - schema: TSchema, - options: FormOptions< - StandardSchemaInput, - TFormValidators, - TSubmitReturn, - unknown - >, -) => FormOptions< - StandardSchemaInput, - TFormValidators, - TSubmitReturn, - TComponents -> - -/** - * Types loose schema form options using a separate schema as the source of the - * final valid form shape. - * - * `defaultValues` infer an editable form shape constrained by the schema input, - * so properties may be omitted or contain `null` or `undefined`. Callback - * validator `value` parameters use that editable shape merged with the schema - * input. - * - * The first argument is used only by TypeScript and is ignored at runtime. - * Include the schema in `validators` as well when it should validate the form. - * Parsed results are available in the corresponding `validatorOutputs` entries - * during submission. - * - * @example - * ```ts - * const bookingSchema = z.object({ startDate: z.date() }) - * const bookingOptions = formOptions.looseSchema(bookingSchema, { - * defaultValues: { startDate: null }, - * validators: [ - * { triggers: ['blur'], run: bookingSchema }, - * { - * triggers: ['change'], - * run: ({ value }) => - * value.startDate === null ? 'Choose a date' : undefined, - * }, - * ], - * }) - * ``` - * - * @param schema - Supplies the final valid form shape without registering a - * validator. - * @param options - The form options used to infer the editable form shape. - * @returns The original options object, normalized to `FormOptions` with the - * editable states merged into the schema input. - * @typeParam TComponents - Library-managed. Do not specify explicitly. - * @typeParam TSchema - Library-managed. Do not specify explicitly. - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. - */ -export type FormOptionsLooseSchemaFn = < - const TSchema extends StandardSchemaV1, - const TFormData extends Editable>, - const TFormValidators extends FormValidators< - NoInfer>> - >, - TSubmitReturn, ->( - schema: TSchema, - options: LooseSchemaFormOptions< - StandardSchemaInput, - TFormData, - TFormValidators, - TSubmitReturn - >, -) => FormOptions< - InferUnion>, - TFormValidators, - TSubmitReturn, - TComponents -> - -/** - * The callable API exposed by `formOptions`, including its schema-driven - * inference modes. - * - * Use `formOptions` directly instead of naming this interface in application - * code. - * - * @typeParam TComponents - Library-managed. Do not specify explicitly. - */ -export interface FormOptionsApi { - /** - * Keeps types inferred from `defaultValues`, validators, and submission - * callbacks when form options are declared separately. - * - * `defaultValues` determine the form data shape in this mode. At runtime, - * this returns the original options object and does not create a form or run - * validation. - * - * @remarks - * **Important:** Although this returns the original object unchanged at - * runtime, its type is normalized to `FormOptions`. Optional properties such - * as `validators` therefore remain optional even when supplied. This - * tradeoff enables safer inference and reuse. - * - * @returns The original options object, normalized to `FormOptions` with its - * inferred form data, validator, and submission types. - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. - */ - < - TFormData, - const TFormValidators extends FormValidators, - TSubmitReturn, - >( - options: FormOptions, - ): FormOptions - - /** - * Infers the form data type from a Standard Schema validator and requires - * `defaultValues` to match the schema input. - * - * Use this when the schema represents an input-to-output pipeline. Raw form - * state remains available as `value`; read each validator's parsed output - * from the corresponding `validatorOutputs` entry during submission. - * - * Pass the schema as the first argument and the options as the second. This - * fixes the form data to the schema input before the options are inferred, so - * each callback receives a typed `value`. The first argument is ignored at - * runtime; include the schema in `validators` when it should run. - * - * @remarks - * **Important:** Although this returns the original object unchanged at - * runtime, its type is normalized to `FormOptions`. Optional properties such - * as `validators` therefore remain optional even when supplied. This - * tradeoff enables safer inference and reuse. - * - * @example - * ```ts - * const profileSchema = z.object({ name: z.string().min(1) }) - * const profileOptions = formOptions.strictSchema(profileSchema, { - * defaultValues: { name: '' }, - * validators: [ - * { - * triggers: ['change'], - * run: profileSchema, - * }, - * { - * triggers: ['change'], - * run: ({ value }) => - * value.name.length === 0 ? 'Name is required' : undefined, - * }, - * ], - * onSubmit: ({ validatorOutputs }) => saveProfile(validatorOutputs[0]), - * }) - * ``` - * - * @returns The original options object, normalized to `FormOptions` with the - * schema's input shape. - * @typeParam TSchema - Library-managed. Do not specify explicitly. - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. - */ - strictSchema: FormOptionsStrictSchemaFn - - /** - * Infers the form data shape from a Standard Schema validator while allowing - * editable defaults to omit properties or contain `null` or `undefined` - * values. - * - * Use this when the schema represents the final valid shape but the UI needs - * intermediate empty states, such as an unselected date. Raw form state - * remains available as `value`; read each validator's parsed output from the - * corresponding `validatorOutputs` entry during submission. - * - * Pass the schema as the first argument and the options as the second. - * `defaultValues` infer an editable form shape constrained by the schema - * input, and callbacks receive that shape merged with the schema input. The - * first argument is ignored at runtime; include the schema in `validators` - * when it should run. - * - * @remarks - * **Important:** Although this returns the original object unchanged at - * runtime, its type is normalized to `FormOptions`. Optional properties such - * as `validators` therefore remain optional even when supplied. This - * tradeoff enables safer inference and reuse. - * - * @example - * ```ts - * const bookingSchema = z.object({ startDate: z.date() }) - * const bookingOptions = formOptions.looseSchema(bookingSchema, { - * defaultValues: { startDate: null }, - * validators: [ - * { - * triggers: ['blur'], - * run: bookingSchema, - * }, - * { - * triggers: ['change'], - * run: ({ value }) => - * value.startDate === null ? 'Choose a date' : undefined, - * }, - * ], - * onSubmit: ({ validatorOutputs }) => saveBooking(validatorOutputs[0]), - * }) - * ``` - * - * @returns The original options object, normalized to `FormOptions` with - * omitted, nullable, and undefined editable states merged into the schema's - * input shape. - * @typeParam TSchema - Library-managed. Do not specify explicitly. - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. - */ - looseSchema: FormOptionsLooseSchemaFn -} - -/** - * Keeps form data, validator, and submission types inferred when options are - * declared separately from a framework's form creation API. - * - * The regular helper takes `defaultValues` at face value as the form data - * shape. For schema-driven inference, use `formOptions.strictSchema` when the - * schema defines an input-to-output boundary, or `formOptions.looseSchema` when - * the schema defines the shape but editable defaults may omit properties or - * need `null` or `undefined` values. - * - * At runtime, this is an identity helper: it returns the original options - * object and does not create a form or run validation. - * - * @remarks - * **Important:** Although this returns the original object unchanged at - * runtime, its type is normalized to `FormOptions`. Optional properties such - * as `validators` therefore remain optional even when supplied. This tradeoff - * enables safer inference and reuse. - * - * @example - * ```ts - * const profileOptions = formOptions({ - * defaultValues: { name: '' }, - * validators: [ - * { - * triggers: ['change'], - * run: ({ value }) => - * value.name.length === 0 ? 'Name is required' : undefined, - * }, - * ], - * }) - * - * const form = useForm({ - * ...profileOptions, - * onSubmit: ({ value }) => saveProfile(value), - * }) - * ``` - */ -const formOptions = ((opts) => opts) as FormOptionsApi - -formOptions.strictSchema = ((_schema: unknown, opts: unknown) => opts) as never -formOptions.looseSchema = ((_schema: unknown, opts: unknown) => opts) as never - -export { formOptions } diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/validation.public.ts index 02e3ce3ae3..b175be78bd 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/validation.public.ts @@ -1,5 +1,5 @@ import type { DeepKeys } from './deep-keys.public' -import type { FormApi, OnSubmitError } from './FormApi/FormApi.public' +import type { FormApi } from './FormApi/FormApi.public' import type { AnyFieldApi, FieldApi } from './FieldApi/FieldApi.public' import type { FormGroupApi } from './FormGroupApi/FormGroupApi.public' import type { @@ -7,7 +7,19 @@ import type { StandardSchemaV1Issue, } from './standardSchema.public' import type { OneOrMany, ReadonlyOneOrMany } from './types.public' -import type { VALIDATION_OUTPUT } from './validationOutput.lib' +import type { + CreateErrorMapFn, + CreateOutputFn, + ScopedErrorTypes, + ValidationErrorMap, + ValidationIssue, + ValidationOutput, +} from './validation/state.public' +import type { OnSubmitError } from './FormApi/formSubmit.public' +import type { + ErrorVisibilityContext, + ErrorVisibilityFieldState, +} from './FormApi/errorVisibility.public' /** * A validation function or Standard Schema with an optional rule to stop after earlier failures. @@ -49,11 +61,11 @@ export interface Validator< * Whether this validator runs during a submission attempt, independently of `triggers`. * * Pass a boolean to enable or disable it, or pass a function that returns - * `true` to enable it for the current attempt. + * `true` to skip it for the current attempt. * - * @default true + * @default false */ - runOnSubmit?: + skipOnSubmit?: boolean | ValidationPredicateFn /** * Whether this validator runs when the form is constructed or when its field @@ -77,7 +89,7 @@ export interface Validator< * Change and blur events that can run this validator, optionally with conditions. * * An empty array disables change and blur validation. Configure submission - * separately with `runOnSubmit` and initial validation with `runOnMount`. + * separately with `skipOnSubmit` and initial validation with `runOnMount`. */ triggers: Array< ValidationTriggerOption @@ -122,13 +134,9 @@ type ValidatorWithRun< type InferFormDataFromValidator = TValidator extends StandardSchemaV1 ? TFormData - : TValidator extends FormValidatorFn - ? TFormData - : TValidator extends ServerFormValidatorFn - ? TFormData - : TValidator extends FormGroupValidatorFn - ? TGroupValue - : any + : TValidator extends FormGroupValidatorFn + ? TGroupValue + : any /** * A validator created by combining reusable options with a validation function @@ -255,93 +263,13 @@ export type ValidationTrigger = 'change' | 'blur' | 'submit' export type ServerValidationTrigger = 'server' /** Events reported to client-side validation callbacks. */ export type ClientValidationTrigger = ValidationTrigger -/** Events configured through `triggers`. Submission is controlled by `runOnSubmit`. */ +/** Events configured through `triggers`. Submission is controlled by `skipOnSubmit`. */ export type ConfigurableValidationTrigger = Exclude /** Change and blur events accepted by shared validator configurations. */ export type ValidatorTrigger = ConfigurableValidationTrigger /** The form, form group, or field whose value a validator checks. */ export type ValidatorScope = 'form' | 'group' | 'field' -/** Descendant field state available when deciding whether to show errors. */ -export interface ErrorVisibilitySubfieldsMeta { - /** `true` when no descendant field is marked as dirty. */ - isEveryPristine: boolean - /** `true` when at least one descendant field is marked as dirty. */ - isSomeDirty: boolean - /** `true` when at least one descendant field is marked as touched. */ - isSomeTouched: boolean - /** `true` while at least one descendant field is validating. */ - isSomeValidating: boolean -} - -/** Field state flags available to an error visibility policy. */ -export interface ErrorVisibilityFieldMeta { - /** `true` when this field or a descendant is marked as touched. */ - isTouched: boolean - /** `true` when this field itself is marked as touched. */ - isSelfTouched: boolean - /** `true` when this field or a descendant is marked as dirty. */ - isDirty: boolean - /** `true` when this field itself is marked as dirty. */ - isSelfDirty: boolean - /** `true` when neither this field nor its descendants are marked as dirty. */ - isPristine: boolean - /** `true` when the current value equals the field's default value. */ - isDefaultValue: boolean - /** `true` when this field is marked as blurred. */ - isBlurred: boolean - /** `true` while this field or a descendant is validating. */ - isValidating: boolean - /** `true` while this field itself is validating. */ - isSelfValidating: boolean - /** State flags for this field's descendants. */ - subfields: ErrorVisibilitySubfieldsMeta -} - -/** The field value and state flags available to an error visibility policy. */ -export interface ErrorVisibilityFieldState { - /** The current value of the field whose errors the policy controls. */ - value: any - /** State flags before the visibility policy is applied. */ - meta: ErrorVisibilityFieldMeta -} - -/** - * Form and field state used to decide whether to show a field's errors. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export interface ErrorVisibilityContext< - in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, -> { - /** - * Current form state. For a field in a form group, flags and submission - * counters describe that group. `values` and `errors` describe the whole form. - */ - state: FormApi['state'] - /** The field whose errors the policy controls. */ - fieldState: ErrorVisibilityFieldState -} - -/** - * Returns `true` to show a field's validation errors in `field.errors`. - * - * Hidden errors remain available in `field.meta.original.errors`. - * - * For fields inside a form group, `state` properties such as `isTouched` and - * `submissionAttempts` describe the containing group. `values` and `errors` - * still describe the whole form. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export type ErrorVisibility< - in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, -> = (context: ErrorVisibilityContext) => boolean - /** * Form or group state available to a reusable error visibility policy. * @@ -369,11 +297,8 @@ export interface ReusableErrorVisibilityContext { * @typeParam TFormData - Library-managed. Do not specify explicitly. * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. */ -export type ReusableErrorVisibility = < - TFormData, - TFormErrorTypes extends FormErrorTypes, ->( - context: ErrorVisibilityContext, +export type ReusableErrorVisibility = ( + context: ErrorVisibilityContext, ) => boolean /** @@ -573,13 +498,6 @@ export type FormValidationTriggerOption< | ClientValidationTriggerOption | ServerValidationTrigger -/** - * A validation error with a display message. - */ -export interface ValidationIssue { - /** The text to display for this error. */ - message: string -} /** An error supplied as an issue object or a string that becomes its `message`. */ export type ValidationErrorValue = ValidationIssue | string /** One or more issue objects after string errors have been normalized. */ @@ -587,92 +505,6 @@ export type ValidationError = OneOrMany /** One or more errors accepted from a validator, including string messages. */ export type ValidationErrorInput = ReadonlyOneOrMany -/** - * Errors owned by a form or group and errors routed to its fields. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export interface ValidationErrorMap { - /** Errors for the form or group that owns the validator. */ - form?: ValidationErrorInput - /** Errors keyed by field path. Paths are relative to the group for group validators. */ - fields: Partial, ValidationErrorInput>> -} - -/** - * Creates an error map with field paths inferred from the callback's value shape. - * - * Return the map from a form, group, or server validator to report its errors. - * Pass Standard Schema issues to retain all issues in `form` and also route - * issues with paths to `fields`. Narrow possibly undefined issue lists first. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export interface CreateErrorMapFn { - ( - initial?: Partial>, - ): ValidationErrorMap - /** Converts issues into a map containing only Standard Schema issue arrays. */ - ( - issues: ReadonlyArray, - ): ParsedStandardSchemaIssues -} - -/** - * Standard Schema issues reported at form or group scope and routed to field paths. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export interface ParsedStandardSchemaIssues { - /** All supplied issues, including those with field paths. */ - form: Array - /** Issues with paths, grouped by field path relative to the validator's value. */ - fields: Partial, Array>> -} - -/** - * A successful validation result that carries data for `validatorOutputs`. - * - * Return the result of the validator context's `createOutput` helper to provide - * data during submission or server validation. - * - * @typeParam TOutput - Library-managed. Do not specify explicitly. - */ -export interface ValidationOutput { - readonly [VALIDATION_OUTPUT]: TOutput -} - -/** - * Creates a successful validation result with data for `validatorOutputs`. - * - * Return this result from a form or group validator. The payload appears at that - * validator's index during submission or server validation and is forwarded - * without mutation. Outputs from change, blur, and mount validation are **not** - * retained for later submission. - * - * @param value - The payload to expose at this validator's index. - * @returns A successful validator result containing the original payload. - * @typeParam TOutput - Library-managed. Do not specify explicitly. - * - * @example - * ```ts - * formOptions({ - * defaultValues: { name: '' }, - * validators: [{ - * triggers: [], - * run: ({ value, createOutput }) => createOutput(value.name.trim()), - * }], - * onSubmit: ({ validatorOutputs }) => { - * const trimmedName = validatorOutputs[0] - * // ... - * }, - * }) - * ``` - */ -export type CreateOutputFn = ( - value: TOutput, -) => ValidationOutput - /** * Values and helpers passed to a form validation function. * @@ -729,21 +561,6 @@ export type SuccessfulValidationResult = */ export type ValidationResult = ValidValidationResult | ValidationErrorInput -/** - * Errors for a form, optionally including errors routed to individual fields. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export type FormValidationError = - ValidationErrorInput | ValidationErrorMap -/** - * A form validator's success result, output data, or validation errors. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export type FormValidateResult = - SuccessfulValidationResult | FormValidationError - /** * A validation callback that can return a result immediately or asynchronously. * @@ -754,77 +571,6 @@ export type ValidatorFn = ( ...args: Array ) => TReturn | Promise -/** - * Validates complete form values and can return form errors, field errors, or output data. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export type FormValidatorFn = ValidatorFn< - FormValidatorContext, - FormValidateResult -> - -/** - * Validates submitted form values on the server without a client form instance. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export type ServerFormValidatorFn = ValidatorFn< - ServerFormValidatorContext, - FormValidateResult -> - -/** - * Configures validation of complete form values on the client or server. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export interface FormValidator extends BaseValidator< - FormValidatorFn | StandardSchemaV1 -> { - /** - * Whether this validator runs during a client submission attempt, independently of `triggers`. - * - * Pass a boolean to enable or disable it, or pass a function that returns - * `true` to enable it for the current attempt. - * - * @default true - */ - runOnSubmit?: boolean | ValidationPredicateFn - /** - * Whether this validator runs when the form is constructed. - * - * @default false - */ - runOnMount?: boolean - /** - * The delay in milliseconds before validation runs for change or blur events. - * - * Pass a number for a fixed delay, or pass a function that returns the delay - * for the current event. Repeated events restart the delay. - * Submission and server validation run immediately. - * - * @default 0 - */ - triggerDebounceMs?: - number | ValidationDebounceFn - /** - * Change and blur events that can run this validator, optionally with conditions. - * Add `'server'` to include it in `serverValidate`. - * - * An empty array disables change, blur, and server validation. Configure client - * submission separately with `runOnSubmit` and initial validation with `runOnMount`. - */ - triggers: Array> -} - -/** - * Form validators in configuration order, which also determines their `validatorOutputs` indexes. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export type FormValidators = ReadonlyArray> - /** * Values and helpers passed to a form group validation function. * @@ -855,7 +601,7 @@ export interface FormGroupValidatorContext { * @typeParam TGroupValue - Library-managed. Do not specify explicitly. */ export type FormGroupValidationError = - ValidationErrorInput | ValidationErrorMap + ValidationErrorInput | ValidationErrorMap /** * A form group validator's success result, output data, or validation errors. * @@ -997,7 +743,7 @@ type ExtractErrorMap< TTarget extends ValidationErrorTarget, > = TResult extends ValidationIssue ? NormalizeValidationResult - : TResult extends ValidationErrorMap + : TResult extends ValidationErrorMap ? TTarget extends 'form' ? TResult extends { form?: infer TError } ? NormalizeValidationResult @@ -1076,37 +822,6 @@ export type FieldErrors = Array< unknown extends TFieldError ? ValidationIssue : TFieldError > -type ValidationErrorValueFromType = unknown extends TError - ? ValidationErrorValue - : | Extract - | (ValidationIssue extends TError ? string : never) - -type ValidationErrorInputFromType = [TError] extends [never] - ? never - : ReadonlyOneOrMany> - -/** - * Form validation results constrained to inferred issue types, as stored in server form state. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export type FormValidateResultFromErrorTypes< - TFormData, - TFormErrorTypes extends FormErrorTypes, -> = - | ValidValidationResult - | ValidationErrorInputFromType - | { - form?: ValidationErrorInputFromType - fields: Partial< - Record< - DeepKeys, - ValidationErrorInputFromType - > - > - } - // Scalars and branded returns share the runtime success classification. Error // collections additionally allow success when they contain no stored errors. type SuccessWithoutOutput = @@ -1120,7 +835,7 @@ type SuccessWithoutOutput = : never : TResult extends ValidationIssue ? never - : TResult extends ValidationErrorMap + : TResult extends ValidationErrorMap ? TResult extends { form: infer TForm } ? [SuccessWithoutOutput] extends [never] ? never @@ -1157,28 +872,17 @@ type TryGetOutput = TValidator extends { type TryGetSubmitOutput = TryGetOutput extends infer TOutput - ? TValidator extends { readonly runOnSubmit: infer TRunOnSubmit } - ? [TRunOnSubmit] extends [false] - ? undefined // User explicitly set runOnSubmit: false -> guaranteed undefined - : TRunOnSubmit extends (...args: Array) => boolean + ? TValidator extends { readonly skipOnSubmit: infer TSkipOnSubmit } + ? [TSkipOnSubmit] extends [true] + ? undefined // User explicitly set skipOnSubmit: true -> guaranteed undefined + : TSkipOnSubmit extends (...args: Array) => boolean ? TOutput | undefined // Callback could dynamically be true or false -> union - : false extends TRunOnSubmit + : true extends TSkipOnSubmit ? TOutput | undefined // the explicit variable is boolean, so also dynamic -> union : TOutput : TOutput // default, which is guaranteed present : never -type ValidatorTriggers = TValidator extends { - readonly triggers: infer TTriggers -} - ? TTriggers extends ReadonlyArray - ? TTriggers[number] - : never - : never - -type HasServerTrigger = - ServerValidationTrigger extends ValidatorTriggers ? true : false - type TryGetFormError = TValidator extends { readonly run: StandardSchemaV1 } @@ -1212,18 +916,6 @@ type ToOutputs< ? Array : MappedOutputs -/** - * Infers submission output slots from form validators in configuration order. - * - * Slots contain Standard Schema outputs or payloads returned through `createOutput`. - * Validators disabled for submission have `undefined` slots. Conditional - * submission or success without output can also make a slot `undefined`. - * - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - */ -export type ToFormOutputs> = - ToOutputs> - /** * Infers submission output slots from group validators in configuration order. * @@ -1255,30 +947,30 @@ type ExtractValidatorFieldError< ? never : TryGetFieldError -type ToValidatorErrorTypes< +type GetSelfErrors< TValidators extends ReadonlyArray, TBroadValidators extends ReadonlyArray, TSubmitReturn, -> = FormErrorTypes< +> = | ExtractValidatorFormError - | ExtractSubmitFormError, + | ExtractSubmitFormError + +type GetSubfieldErrors< + TValidators extends ReadonlyArray, + TBroadValidators extends ReadonlyArray, + TSubmitReturn, +> = | ExtractValidatorFieldError | ExtractSubmitFieldError -> -/** - * Infers form and routed field issue types from validators and submission validation errors. - * - * String errors become `ValidationIssue` objects. Successful output payloads - * are excluded from error types. - * - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. - */ -export type ToFormErrorTypes< - TFormValidators extends FormValidators, +type ToValidatorErrorTypes< + TValidators extends ReadonlyArray, + TBroadValidators extends ReadonlyArray, TSubmitReturn, -> = ToValidatorErrorTypes, TSubmitReturn> +> = ScopedErrorTypes< + GetSelfErrors, + GetSubfieldErrors +> /** * Infers group and routed field issue types from group validators. @@ -1311,49 +1003,3 @@ export type ToFieldError< | (unknown extends TGroupFieldError ? never : TGroupFieldError) | ExtractFormFieldError > - -type MappedServerFormValidators< - in out TFormValidators extends FormValidators, -> = { - [K in keyof TFormValidators]: HasServerTrigger< - TFormValidators[K] - > extends true - ? TFormValidators[K] - : never -} - -/** - * Infers form and field issue types from validators with a `'server'` trigger. - * - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - */ -export type ToServerFormErrorTypes< - TFormValidators extends FormValidators, -> = unknown extends TFormValidators - ? FormErrorTypes - : FormValidators extends TFormValidators - ? FormErrorTypes - : ToFormErrorTypes, never> - -type MappedServerOutputs> = { - [K in keyof TFormValidators]: HasServerTrigger< - TFormValidators[K] - > extends true - ? TryGetOutput - : undefined -} - -/** - * Infers server validation output slots in form validator configuration order. - * - * Validators without a `'server'` trigger have `undefined` slots. Server - * selection is independent of `runOnSubmit`. - * - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - */ -export type ToServerFormOutputs> = - unknown extends TFormValidators - ? Array - : FormValidators extends TFormValidators - ? Array - : MappedServerOutputs diff --git a/packages/form-core/src/validation/errors.lib.ts b/packages/form-core/src/validation/errors.lib.ts index 7a96dec81a..ffebf8c2a0 100644 --- a/packages/form-core/src/validation/errors.lib.ts +++ b/packages/form-core/src/validation/errors.lib.ts @@ -6,18 +6,22 @@ import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { FieldValidateResult, FormGroupValidateResult, - FormValidateResult, SuccessfulValidationResult, ValidationErrorInput, +} from '../validation.public' +import type { + FormValidationResult, ValidationErrorMap, + ValidationFailure, ValidationIssue, -} from '../validation.public' + ValidationSuccess, +} from './state.public' type ValidateResult = - FormValidateResult | FormGroupValidateResult | FieldValidateResult + FormValidationResult | FormGroupValidateResult | FieldValidateResult export function normalizeValidationError( - value: ValidationErrorInput | null | undefined, + value: ValidationErrorInput | ValidationFailure | null | undefined | void, ): Array { return normalizeToArray(value).map((error) => typeof error === 'string' ? { message: error } : error, @@ -36,7 +40,7 @@ export interface ParsedValidationResult { */ export function isValidationErrorMap( value: unknown, -): value is ValidationErrorMap { +): value is ValidationErrorMap { if (typeof value !== 'object') return false if (value === null) return false if (Array.isArray(value)) return false @@ -95,7 +99,8 @@ export function parseValidationResult( */ export function isErrorResult( value: T, -): value is Exclude { + // TODO merge these two after migration +): value is Exclude { const { self, subfields } = parseValidationResult(value) return ( diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index 1cb82f269c..1171e4e90c 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -1,7 +1,10 @@ import { isStandardSchema, parseStandardSchema } from '../standardSchema.lib' import { VALIDATION_OUTPUT, isValidationOutput } from '../validationOutput.lib' import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' -import type { InternalFormApi } from '../FormApi/FormApi.lib' +import type { + AnyInternalFormApi, + InternalFormApi, +} from '../FormApi/FormApi.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { FieldValidateResult, @@ -10,20 +13,20 @@ import type { FormGroupValidateResult, FormGroupValidator, FormGroupValidatorContext, - FormValidateResult, - FormValidator, FormValidatorContext, ServerFormValidatorContext, ValidationPredicateContext, ValidationTriggerOption, Validator, } from '../validation.public' +import type { FormValidationResult } from './state.public' +import type { FormValidator } from './formValidators.public' type FormValidateContext = { scope: 'form' event: Exclude['event'], 'server'> signal: AbortSignal - formApi: InternalFormApi + formApi: AnyInternalFormApi triggerFieldApi?: AnyInternalFieldApi } type ServerPipelineValidateContext = { @@ -56,9 +59,9 @@ export type ValidateContext = | FieldValidateContext | FormGroupValidateContext export type ValidateResult = - FormValidateResult | FormGroupValidateResult | FieldValidateResult + FormValidationResult | FormGroupValidateResult | FieldValidateResult type AnyPipelineValidator = - | FormValidator + | FormValidator | FormGroupValidator | FieldValidator | Validator @@ -105,6 +108,7 @@ function isServerTrigger( /** Checks whether a validator participates in server validation. */ function hasServerTrigger(validator: AnyPipelineValidator): boolean { + if (!validator.triggers) return false return validator.triggers.some(isServerTrigger) } @@ -219,12 +223,13 @@ export function shouldRunValidator( return hasServerTrigger(validator) } - const { runOnSubmit = true } = validator + const { skipOnSubmit = false } = validator if (context.event === 'submit') { - return getEnabledState(runOnSubmit, context) + return getEnabledState(skipOnSubmit, context) } + if (!validator.triggers) return false return validator.triggers.some((signal) => isValidationTriggerEnabled(signal, context), ) diff --git a/packages/form-core/src/validation/formValidators.public.ts b/packages/form-core/src/validation/formValidators.public.ts index 8534a04d5e..1faf798859 100644 --- a/packages/form-core/src/validation/formValidators.public.ts +++ b/packages/form-core/src/validation/formValidators.public.ts @@ -238,6 +238,7 @@ export interface ValidatorPredicateContext { value: TValue } +// TODO this should be merged into all validators. This doesn't change for field, group and form. /** * Synchronously decides whether a trigger is enabled or submission validation * is skipped. diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index 65cb22ce5a..35eedc0d51 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -8,13 +8,15 @@ import { normalizeValidatorResult, } from './execution.lib' import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' -import type { InternalFormApi } from '../FormApi/FormApi.lib' +import type { + AnyInternalFormApi, + InternalFormApi, +} from '../FormApi/FormApi.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { AnyInternalFormGroupApi } from '../FormGroupApi/FormGroupApi.lib' import type { FieldValidateResult, FormGroupValidateResult, - FormValidateResult, } from '../validation.public' import type { AnyValidatorContext, @@ -22,11 +24,12 @@ import type { ValidatorExecutionResult, } from './execution.lib' import type { PipelineResult } from './pipeline.lib' +import type { FormValidationResult } from './state.public' interface FormMountValidatorPipelineArgs { pipeline: ReadonlyArray - formApi: InternalFormApi - onResult?: (result: PipelineResult>) => void + formApi: AnyInternalFormApi + onResult?: (result: PipelineResult>) => void } export interface FormMountValidatorPipelineResult { @@ -275,7 +278,7 @@ export function runFormMountValidatorPipeline({ formApi, onResult, }: FormMountValidatorPipelineArgs): FormMountValidatorPipelineResult { - return runMountValidatorPipeline>({ + return runMountValidatorPipeline>({ pipeline, getContext: (signal) => ({ event: 'mount' as never, diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts index 1e4682a0d5..da81fd4e31 100644 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -13,10 +13,6 @@ import { } from './execution.lib' import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' -import type { - FieldValidateResult, - FormValidateResult, -} from '../validation.public' import type { AbortedCall, AnyValidatorContext, @@ -28,6 +24,8 @@ import type { ValidateResult, ValidatorExecutionResult, } from './execution.lib' +import type { FormValidationResult } from './state.public' +import type { FieldValidateResult } from '../validation.public' export interface PipelineResult { validatorInstance: AnyInternalValidatorInstance @@ -225,11 +223,11 @@ interface FormValidatorPipelineArgs { * Whether previous pipelines have reported an error or not. */ hasFailedBefore: boolean - onResult?: (result: PipelineResult>) => void + onResult?: (result: PipelineResult>) => void } export interface FormValidatorPipelineResult { - results: Array>> + results: Array>> hasErrors: boolean thrownError: unknown | null } @@ -241,7 +239,7 @@ export function runFormValidatorPipeline({ onResult, hasFailedBefore, }: FormValidatorPipelineArgs): Promise { - return runValidatorPipeline>({ + return runValidatorPipeline>({ pipeline, context, onResult, diff --git a/packages/form-core/src/validation/state.public.ts b/packages/form-core/src/validation/state.public.ts index 5f56f710b7..9cff4dd672 100644 --- a/packages/form-core/src/validation/state.public.ts +++ b/packages/form-core/src/validation/state.public.ts @@ -1,5 +1,5 @@ import type { DeepKeys } from '../deep-keys.public' -import type { OnSubmitError } from '../FormApi/FormApi.public' +import type { OnSubmitError } from '../FormApi/formSubmit.public' import type { StandardSchemaV1, StandardSchemaV1Issue, @@ -14,6 +14,35 @@ export interface ValidationIssue { message: string } +/** + * The result of `createOutput`. Return it from a validator to store its data + * during submission. + * + * @typeParam TOutput - Library-managed. Do not specify explicitly. + * + * @example + * ```ts + * formOptions({ + * defaultValues: { name: '' }, + * validators: [ + * { + * run: ({ createOutput, value }) => { + * return createOutput({ displayName: value.name.trim() }) + * }, + * }, + * { + * // Schemas pass their output data by default. + * run: mySchema, + * }, + * ], + * onSubmit: ({ validatorOutputs }) => { + * // Outputs follow the same order as the validators array. + * const [customData, schemaOutput] = validatorOutputs + * console.log(customData.displayName) + * }, + * }) + * ``` + */ export interface ValidationOutput { readonly [VALIDATION_OUTPUT]: TOutput } @@ -204,3 +233,9 @@ export type ToFormErrorTypes = TScope extends ScopedErrorTypes ? FormErrorTypes : never + +// TODO get rid of this to complete migration +export type ToScopedErrorTypes = + TError extends FormErrorTypes + ? ScopedErrorTypes + : never diff --git a/packages/form-core/src/validationOutput.lib.ts b/packages/form-core/src/validationOutput.lib.ts index a1f2fc3c4d..5e342a34b0 100644 --- a/packages/form-core/src/validationOutput.lib.ts +++ b/packages/form-core/src/validationOutput.lib.ts @@ -1,7 +1,7 @@ import type { - SuccessfulValidationResult, ValidationOutput, -} from './validation.public' + ValidationSuccess, +} from './validation/state.public' export const VALIDATION_OUTPUT = Symbol('VALIDATION_OUTPUT') @@ -21,7 +21,7 @@ export function isValidationOutput( export function isSuccessfulValidationResult( value: unknown, -): value is SuccessfulValidationResult { +): value is ValidationSuccess { return ( value === null || value === undefined || From e7263c295c69c8b65293dc4264785d3b869f68eb Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:18:37 +0200 Subject: [PATCH 11/43] chore: migrate and clean up unit tests --- .../form-core/src/validation/execution.lib.ts | 3 +- .../tests/FieldApi/FieldApi.lib.spec.ts | 128 +++ .../tests/FormApi/FormApi.lib.spec.ts | 766 ++++++++++++++ .../form-core/tests/FormApi/lifecycle.spec.ts | 953 ------------------ .../tests/devtoolsBridge.lib.spec.ts | 76 ++ 5 files changed, 972 insertions(+), 954 deletions(-) create mode 100644 packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts create mode 100644 packages/form-core/tests/FormApi/FormApi.lib.spec.ts delete mode 100644 packages/form-core/tests/FormApi/lifecycle.spec.ts create mode 100644 packages/form-core/tests/devtoolsBridge.lib.spec.ts diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index 1171e4e90c..da9297967b 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -226,7 +226,8 @@ export function shouldRunValidator( const { skipOnSubmit = false } = validator if (context.event === 'submit') { - return getEnabledState(skipOnSubmit, context) + // skipOnSubmit is inversed + return !getEnabledState(skipOnSubmit, context) } if (!validator.triggers) return false diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts new file mode 100644 index 0000000000..08be3d17e4 --- /dev/null +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' + +describe('InternalFieldApi - _clearEventErrors', () => { + it('clears submit-only validator errors only for the field that changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const nameField = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is required', triggers: [] }], + }) + const emailField = form._getOrCreateFieldApi({ + name: 'email', + validators: [{ run: () => 'Email is required', triggers: [] }], + }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + it('clears submit errors when the field validator only listens for blur', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const nameField = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is required', triggers: ['blur'] }], + }) + const emailField = form._getOrCreateFieldApi({ + name: 'email', + validators: [{ run: () => 'Email is required', triggers: ['blur'] }], + }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + it('preserves errors produced by blur validation when the field changes', async () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is required', triggers: ['blur'] }], + }) + field._register() + + field.handleBlur() + await expect.poll(() => form.state.isValidating).toBe(false) + expect(field.errors).toEqual([{ message: 'Name is required' }]) + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(field.errors).toEqual([{ message: 'Name is required' }]) + }) + + it('replaces submit errors with the result of field change validation', async () => { + const validator = vi + .fn() + .mockReturnValueOnce('Submit field validator failed') + .mockReturnValue('Change field validator failed') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: validator, triggers: ['change'] }], + }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Submit field validator failed' }]) + expect(validator).toHaveBeenLastCalledWith( + expect.objectContaining({ event: 'submit' }), + ) + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(field.errors).toEqual([{ message: 'Change field validator failed' }]) + expect(validator).toHaveBeenLastCalledWith( + expect.objectContaining({ event: 'change' }), + ) + }) + + it('clears submit errors when field change validation is disabled by its predicate', async () => { + const validator = vi.fn(() => 'Submit field validator failed') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + run: validator, + triggers: [{ trigger: 'change', when: false }], + }, + ], + }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Submit field validator failed' }]) + validator.mockClear() + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(field.errors).toEqual([]) + expect(validator).not.toHaveBeenCalled() + }) +}) diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts new file mode 100644 index 0000000000..224dd14979 --- /dev/null +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -0,0 +1,766 @@ +/// + +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { formOptions } from '../../src/FormApi/formOptions.public' + +describe('InternalFormApi - constructor', () => { + it('initializes default values with untouched, submittable state', () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice', age: 30 }, + }) + + expect(form.state).toMatchObject({ + values: { name: 'Alice', age: 30 }, + isTouched: false, + canSubmit: true, + }) + }) + + it('uses a supplied formId', () => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + + expect(form.formId).toBe('profile-form') + }) +}) + +describe('InternalFormApi - _update', () => { + it('changes a supplied formId', () => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + + form._update({ + formId: 'renamed-profile-form', + defaultValues: { name: '' }, + }) + + expect(form.formId).toBe('renamed-profile-form') + }) + + it('generates a nonempty formId and preserves it when options update', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const formId = form.formId + + expect(formId).toBeTypeOf('string') + expect(formId.length).toBeGreaterThan(0) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.formId).toBe(formId) + }) + + it('applies updated defaults to an untouched field without marking it touched', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + expect(field.value).toBe('') + expect(field.meta.isTouched).toBe(false) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.state.values).toEqual({ name: 'async' }) + expect(form.defaultValues).toEqual({ name: 'async' }) + expect(field.value).toBe('async') + expect(field.meta.isTouched).toBe(false) + expect(form.state.isTouched).toBe(false) + }) + + it('preserves user edits while applying updated defaults to untouched fields', () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const nameField = form._getOrCreateFieldApi({ name: 'name' }) + form._getOrCreateFieldApi({ name: 'email' }) + + nameField.handleChange('user edit') + form._update({ + defaultValues: { name: 'server name', email: 'server@example.com' }, + }) + + expect(form.state.values).toEqual({ + name: 'user edit', + email: 'server@example.com', + }) + expect(form.defaultValues).toEqual({ + name: 'server name', + email: 'server@example.com', + }) + }) + + it('warns when the validator array length changes', ({ onTestFinished }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [{ run: () => null, triggers: [] }], + }) + + form._update({ + defaultValues: { name: '' }, + validators: [ + { run: () => null, triggers: [] }, + { run: () => null, triggers: [] }, + ], + }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the validator array should not change', + ), + ) + }) + + it('uses the updated validator callback on subsequent validation', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [{ run: () => 'Initial error', triggers: ['change'] }], + }) + + await form.validate('change') + expect(form.state.errors).toEqual([{ message: 'Initial error' }]) + + form._update({ + defaultValues: { name: '' }, + validators: [{ run: () => 'Updated error', triggers: ['change'] }], + }) + await form.validate('change') + + expect(form.state.errors).toEqual([{ message: 'Updated error' }]) + }) + + it('uses the updated listener callback and triggers', () => { + const initialListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: initialListener }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('before update') + expect(initialListener).toHaveBeenCalledOnce() + initialListener.mockClear() + + form._update({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['blur'], run: updatedListener }], + }) + field.handleChange('after update') + expect(initialListener).not.toHaveBeenCalled() + expect(updatedListener).not.toHaveBeenCalled() + + field.handleBlur() + + expect(updatedListener).toHaveBeenCalledOnce() + expect(initialListener).not.toHaveBeenCalled() + }) + + it('uses the updated onSubmit callback and replaces its previous errors', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Initial submit error'), + }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Initial submit error' }]) + + form._update({ + defaultValues: { name: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Updated submit error'), + }) + await form.handleSubmit() + + expect(form.state.errors).toEqual([{ message: 'Updated submit error' }]) + }) + + it('retains default listeners when incoming listeners update', () => { + const defaultListener = vi.fn() + const incomingListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi( + { + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: incomingListener }], + }, + { + form: { + listenersMerge: 'append', + listeners: [{ triggers: ['change'], run: defaultListener }], + }, + }, + ) + + form.setFieldValue('name', 'initial') + expect(defaultListener).toHaveBeenCalledOnce() + expect(incomingListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(incomingListener) + + defaultListener.mockClear() + incomingListener.mockClear() + form._update({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: updatedListener }], + }) + form.setFieldValue('name', 'updated') + + expect(defaultListener).toHaveBeenCalledOnce() + expect(updatedListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(updatedListener) + expect(incomingListener).not.toHaveBeenCalled() + }) + + it('preserves reset defaults until the incoming defaults change', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.state.values).toEqual({ name: 'async' }) + expect(form.defaultValues).toEqual({ name: 'async' }) + expect(form.state.isDefaultValue).toBe(true) + + form.reset({ name: 'reset default' }) + + expect(form.state.values).toEqual({ name: 'reset default' }) + expect(form.defaultValues).toEqual({ name: 'reset default' }) + expect(form.state.isDefaultValue).toBe(true) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.state.values).toEqual({ name: 'reset default' }) + expect(form.defaultValues).toEqual({ name: 'reset default' }) + expect(form.state.isDefaultValue).toBe(true) + + form._update({ defaultValues: { name: 'new async' } }) + + expect(form.state.values).toEqual({ name: 'new async' }) + expect(form.defaultValues).toEqual({ name: 'new async' }) + expect(form.state.isDefaultValue).toBe(true) + }) +}) + +describe('InternalFormApi - _getOrCreateFieldApi', () => { + it('attaches nested field listeners only to the requested field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { foo: { bar: '' } }, + }) + const field = form._getOrCreateFieldApi({ + name: 'foo.bar', + listeners: [{ run: listener, triggers: ['change'] }], + }) + const parent = form._getOrCreateFieldApi({ name: 'foo' }) + + parent.handleChange({ bar: 'parent edit' }) + + expect(listener).not.toHaveBeenCalled() + + field.handleChange('field edit') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ fieldApi: field, value: 'field edit' }), + ) + }) +}) + +describe('InternalFormApi - reset', () => { + it('aborts active validation on reset and allows validation to run again', async ({ + onTestFinished, + }) => { + const request = Promise.withResolvers() + onTestFinished(() => request.resolve('Late error')) + const onAbort = vi.fn() + const validator = vi.fn(() => request.promise) + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + run: ({ signal }) => { + signal.addEventListener('abort', onAbort, { once: true }) + return validator() + }, + }, + ], + }) + const form = new InternalFormApi(options) + + const validation = form.validate('change') + expect(validator).toHaveBeenCalledOnce() + expect(form.state.isValidating).toBe(true) + + form.reset() + + expect(onAbort).toHaveBeenCalledOnce() + await expect(validation).resolves.toEqual([]) + request.resolve('Late error') + await request.promise + + expect(form.state.errors).toEqual([]) + expect(form.state.isValidating).toBe(false) + + validator.mockResolvedValue('Current error') + await form.validate('change') + + expect(form.state.errors).toEqual([{ message: 'Current error' }]) + }) + + it('ignores the result of a submission that finishes after reset', async ({ + onTestFinished, + }) => { + const { promise: request, resolve: finishRequest } = + Promise.withResolvers() + onTestFinished(() => finishRequest()) + const options = formOptions({ + defaultValues: { name: '' }, + onSubmit: async ({ createValidationError }) => { + await request + return createValidationError('Late submit error') + }, + }) + const onSubmit = vi.spyOn(options, 'onSubmit') + const form = new InternalFormApi(options) + + const submission = form.handleSubmit() + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + }) + expect(form.state.isSubmitting).toBe(true) + + form.reset({ name: 'reset value' }, { updateDefaultValues: false }) + expect(form.state.isSubmitting).toBe(false) + + finishRequest() + + await expect(submission).resolves.toEqual([]) + expect(form.state).toMatchObject({ + values: { name: 'reset value' }, + errors: [], + isSubmitting: false, + isSubmitSuccessful: false, + submissionAttempts: 0, + }) + }) + + it('restores values and clears field, validation, and submission state', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [{ run: () => 'Validation error', triggers: ['change'] }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + field.handleChange('dirty') + await form.validate('change') + await form.handleSubmit() + + expect(form.state).toMatchObject({ + values: { name: 'dirty' }, + isDirty: true, + isTouched: true, + errors: [{ message: 'Validation error' }], + submissionAttempts: 1, + }) + + form.reset() + + expect(form.state).toMatchObject({ + values: { name: '' }, + isDirty: false, + isTouched: false, + errors: [], + submissionAttempts: 0, + canSubmit: true, + }) + expect(form.getFieldMeta('name')).toBeUndefined() + }) + + it('cancels pending form validation without applying late errors', async () => { + vi.useFakeTimers() + const validator = vi.fn(() => 'Late error') + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + triggerDebounceMs: 100, + run: validator, + }, + ], + }) + + const validation = form.validate('change') + expect(form.state.isValidating).toBe(true) + + form.reset() + await vi.advanceTimersByTimeAsync(100) + + await expect(validation).resolves.toEqual([]) + expect(validator).not.toHaveBeenCalled() + expect(form.state.errors).toEqual([]) + expect(form.state.isValidating).toBe(false) + }) + + it('cancels pending field listener callbacks', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 100, + run: listener, + }, + ], + }) + field._register() + + field.handleChange('first edit') + await vi.advanceTimersByTimeAsync(100) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'first edit' }), + ) + listener.mockClear() + + field.handleChange('pending edit') + form.reset() + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + expect(form.state.values).toEqual({ name: '' }) + }) +}) + +describe('InternalFormApi - resetField', () => { + it('restores the field default and clears touched state and validation errors', async () => { + const form = new InternalFormApi({ defaultValues: { name: 'hi' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Reset me', triggers: [] }], + }) + field._register() + + field.handleChange('bye') + await form.handleSubmit() + + expect(field.value).toBe('bye') + expect(field.meta.isTouched).toBe(true) + expect(field.errors).toEqual([{ message: 'Reset me' }]) + expect(form.state.isValid).toBe(false) + + form.resetField('name') + + expect(field.value).toBe('hi') + expect(field.meta.isTouched).toBe(false) + expect(field.errors).toEqual([]) + expect(form.state.isTouched).toBe(false) + expect(form.state.isValid).toBe(true) + }) + + it('resets descendant values and touched state while keeping fields usable', () => { + const form = new InternalFormApi({ + defaultValues: { person: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'person' }) + const child = form._getOrCreateFieldApi({ name: 'person.name' }) + parent._register() + child._register() + + child.handleChange('Bob') + child.handleBlur() + + expect(parent.meta.isTouched).toBe(true) + expect(child.meta.isTouched).toBe(true) + + form.resetField('person') + + expect(parent.value).toEqual({ name: 'Alice' }) + expect(child.value).toBe('Alice') + expect(parent.meta.isTouched).toBe(false) + expect(child.meta.isTouched).toBe(false) + expect(form.state.isTouched).toBe(false) + + child.handleChange('Carol') + + expect(form.state.values).toEqual({ person: { name: 'Carol' } }) + expect(form.state.isTouched).toBe(true) + }) +}) + +describe('InternalFormApi - deleteField', () => { + it('removes field metadata and prevents further updates through the deleted field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ run: listener, triggers: ['change'] }], + }) + field._register() + field.handleChange('Alice') + + expect(form.getFieldMeta('name')).toMatchObject({ isTouched: true }) + expect(form.state.isTouched).toBe(true) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Alice' }), + ) + listener.mockClear() + + form.deleteField('name') + field.handleChange('Bob') + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + expect(form.state.values).toEqual({ name: 'Alice' }) + expect(form.state.isTouched).toBe(false) + }) + + it('leaves other values and touched state intact when the field does not exist', () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const emailField = form._getOrCreateFieldApi({ name: 'email' }) + emailField.handleChange('alice@example.com') + expect(form.getFieldMeta('name')).toBeUndefined() + + form.deleteField('name') + + expect(form.state.values).toEqual({ name: '', email: 'alice@example.com' }) + expect(emailField.meta.isTouched).toBe(true) + expect(form.state.isTouched).toBe(true) + }) + + it('removes descendant metadata and prevents updates through deleted fields', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { person: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'person' }) + const child = form._getOrCreateFieldApi({ + name: 'person.name', + listeners: [{ run: listener, triggers: ['change'] }], + }) + parent._register() + child._register() + child.handleChange('Bob') + + expect(parent.meta.isTouched).toBe(true) + expect(child.meta.isTouched).toBe(true) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Bob' }), + ) + listener.mockClear() + + form.deleteField('person') + child.handleChange('Carol') + parent.handleChange({ name: 'Dana' }) + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldMeta('person')).toBeUndefined() + expect(form.getFieldMeta('person.name')).toBeUndefined() + expect(form.state.values).toEqual({ person: { name: 'Bob' } }) + expect(form.state.isTouched).toBe(false) + }) +}) + +describe('InternalFormApi - _clearEventErrors', () => { + it('clears submit errors when change validation is disabled by its predicate', async () => { + const validator = vi.fn(() => 'Submit validator failed') + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + run: validator, + triggers: [{ trigger: 'change', when: false }], + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submit validator failed' }]) + validator.mockClear() + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(form.state.errors).toEqual([]) + expect(validator).not.toHaveBeenCalled() + }) + + it('clears onSubmit field errors only for the field that changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createErrorMap }) => + createErrorMap({ + fields: { + name: 'Name is required', + email: 'Email is required', + }, + }), + }) + const nameField = form._getOrCreateFieldApi({ name: 'name' }) + const emailField = form._getOrCreateFieldApi({ name: 'email' }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + // TODO: Clearing onSubmit errors on blur is debated and may change. + it('clears onSubmit field errors only for the field that blurs', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createErrorMap }) => + createErrorMap({ + fields: { + name: 'Name is required', + email: 'Email is required', + }, + }), + }) + const nameField = form._getOrCreateFieldApi({ name: 'name' }) + const emailField = form._getOrCreateFieldApi({ name: 'email' }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleBlur() + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + it('clears form-level onSubmit errors when a field changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Submission failed'), + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submission failed' }]) + expect(form.state.canSubmit).toBe(false) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([]) + expect(form.state.canSubmit).toBe(true) + }) + + // TODO clearing onSubmit errors on blur is debated and may change. + it('clears form-level onSubmit errors when a field blurs', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Submission failed'), + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submission failed' }]) + + field.handleBlur() + + expect(form.state.errors).toEqual([]) + }) + + it('clears submit-only validator errors when a field changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [{ run: () => 'Submit-only validator failed', triggers: [] }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([ + { message: 'Submit-only validator failed' }, + ]) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([]) + }) + + it('clears submit validator errors when the validator only listens for blur', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [ + { run: () => 'Submit or blur validator failed', triggers: ['blur'] }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([ + { message: 'Submit or blur validator failed' }, + ]) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([]) + }) + + it('preserves errors produced by blur validation when a field changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [{ run: () => 'Blur validator failed', triggers: ['blur'] }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.validate('blur') + expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) + }) + + it('replaces submit errors with the result of change validation', async () => { + const options = formOptions({ + defaultValues: { name: '', email: '' }, + validators: [ + { + run: ({ event }) => + event === 'submit' + ? 'Submit validator failed' + : 'Change validator failed', + triggers: ['change'], + }, + ], + }) + const form = new InternalFormApi(options) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submit validator failed' }]) + + field.handleChange('Alice') + + await expect + .poll(() => form.state.errors) + .toEqual([{ message: 'Change validator failed' }]) + }) +}) diff --git a/packages/form-core/tests/FormApi/lifecycle.spec.ts b/packages/form-core/tests/FormApi/lifecycle.spec.ts deleted file mode 100644 index 28bd868b35..0000000000 --- a/packages/form-core/tests/FormApi/lifecycle.spec.ts +++ /dev/null @@ -1,953 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' -import { validationSourceScopes } from '../../src/ValidationSourceInstance.lib' -import { installDevtoolsBridge } from '../../src/devtoolsBridge.lib' -import { defaultInternalBaseFieldMeta } from '../../src/FieldApi/fieldState.lib' -import { InternalValidatorInstance } from '../../src/ValidatorInstance.lib' - -describe('form - lifecycle', () => { - describe('initial state', () => { - it('state.values matches defaultValues', () => { - const form = new InternalFormApi({ - defaultValues: { name: 'Alice', age: 30 }, - }) - expect(form.state.values).toEqual({ name: 'Alice', age: 30 }) - }) - - it('state.isTouched starts as false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.state.isTouched).toBe(false) - }) - - it('state.canSubmit starts as true', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.state.canSubmit).toBe(true) - }) - - it('uses a supplied formId', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - - expect(form.formId).toBe('profile-form') - }) - - it('generates and preserves a formId when one is not supplied', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const formId = form.formId - - expect(formId).toBeTypeOf('string') - expect(formId.length).toBeGreaterThan(0) - - form._update({ defaultValues: { name: 'async' } }) - - expect(form.formId).toBe(formId) - }) - - it('supports changing a supplied formId during update', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - - form._update({ - formId: 'renamed-profile-form', - defaultValues: { name: '' }, - }) - - expect(form.formId).toBe('renamed-profile-form') - }) - - it('notifies an installed devtools bridge during form lifecycle hooks', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - const mountForm = vi.fn() - const unmountForm = vi.fn() - const updateForm = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - mountForm, - unmountForm, - updateForm, - }) - - try { - const unmount = form.mount() - expect(mountForm).toHaveBeenCalledWith(form) - - form._update({ - formId: 'renamed-profile-form', - defaultValues: { name: '' }, - }) - expect(updateForm).toHaveBeenCalledWith(form) - - unmount() - expect(unmountForm).toHaveBeenCalledWith(form) - } finally { - uninstallBridge() - } - }) - - it('stops notifying a devtools bridge after it uninstalls', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - const mountForm = vi.fn() - const unmountForm = vi.fn() - const updateForm = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - mountForm, - unmountForm, - updateForm, - }) - uninstallBridge() - - const unmount = form.mount() - form._update({ - formId: 'renamed-profile-form', - defaultValues: { name: '' }, - }) - unmount() - - expect(mountForm).not.toHaveBeenCalled() - expect(updateForm).not.toHaveBeenCalled() - expect(unmountForm).not.toHaveBeenCalled() - }) - - it('supports updating defaultValues after initialization', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.state.values).toEqual({ name: '' }) - expect(form.state.isTouched).toBe(false) - form._update({ defaultValues: { name: 'async' } }) - - expect(form.state.isTouched).toBe(false) - expect(form.state.values).toEqual({ name: 'async' }) - }) - - it('preserves touched field values when defaultValues update', () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - form._getOrCreateFieldApi({ name: 'email' }) - - nameField.handleChange('user edit') - form._update({ - defaultValues: { name: 'server name', email: 'server@example.com' }, - }) - - expect(form.state.values).toEqual({ - name: 'user edit', - email: 'server@example.com', - }) - }) - - it('warns when the validator array length changes after initialization', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [{ run: () => null, triggers: [] }], - }) - - form._update({ - defaultValues: { name: '' }, - validators: [ - { run: () => null, triggers: [] }, - { run: () => null, triggers: [] }, - ], - }) - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'length of the validator array should not change', - ), - ) - warn.mockRestore() - }) - - it('keeps form validator instances stable by slot across updates', () => { - const firstDefinition = { run: () => null, triggers: [] } - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [firstDefinition], - }) - const instance = form._validatorInstances?.[0] - const nextDefinition = { run: () => null, triggers: [] } - - form._update({ - defaultValues: { name: '' }, - validators: [nextDefinition], - }) - - expect(form._validatorInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(form) - expect(instance?.scope).toBe(validationSourceScopes.form) - expect(instance?.revision).toBe(1) - }) - - it('keeps form listener instances stable and warns on length changes', () => { - const firstDefinition = { - run: () => {}, - triggers: ['change'] as Array<'change'>, - } - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [firstDefinition], - }) - const instance = form._listenerInstances?.[0] - const nextDefinition = { - run: () => {}, - triggers: ['blur'] as Array<'blur'>, - } - - form._update({ - defaultValues: { name: '' }, - listeners: [nextDefinition], - }) - - expect(form._listenerInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(form) - expect(instance?.index).toBe(0) - expect(instance?.revision).toBe(1) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - form._update({ defaultValues: { name: '' }, listeners: [] }) - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'length of the listener array should not change', - ), - ) - expect(form._listenerInstances).toBeNull() - expect(instance?.disposed).toBe(true) - warn.mockRestore() - }) - - it('keeps the onSubmit source stable across callback updates', () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const onSubmitSource = form._onSubmitSource - - form._update({ - defaultValues: { name: '' }, - onSubmit: () => null, - }) - expect(form._onSubmitSource).toBe(onSubmitSource) - - form._update({ - defaultValues: { name: '' }, - onSubmit: () => ({ message: 'updated' }), - }) - - expect(form._onSubmitSource).toBe(onSubmitSource) - expect(onSubmitSource.owner).toBe(form) - expect(onSubmitSource.scope).toBe(validationSourceScopes.onSubmit) - expect(onSubmitSource.index).toBe(0) - }) - - it('resets form validator runtime without replacing its instance', () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [{ run: () => null, triggers: [] }], - }) - const instance = form._validatorInstances?.[0] - const abortController = new AbortController() - instance?.setAbortController(abortController) - instance?.setOutput({ - output: 'output', - hasOutput: true, - }) - - form.reset() - - expect(form._validatorInstances?.[0]).toBe(instance) - expect(abortController.signal.aborted).toBe(true) - expect(instance?.hasOutput).toBe(false) - expect(instance?.disposed).toBe(false) - }) - - it('should only apply options to the leaf node', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { foo: { bar: '' } }, - }) - const field = form._getOrCreateFieldApi({ - name: 'foo.bar', - listeners: [{ run: listener, triggers: ['change'] }], - }) - - field.handleChange('New value') - - await vi.runAllTimersAsync() - - expect(listener).toHaveBeenCalledOnce() - }) - - // TODO extend with default state - }) - - describe('default options', () => { - it('resolves defaults during construction and updates', () => { - const calls: Array = [] - const form = new InternalFormApi( - { - defaultValues: { name: '' }, - listeners: [ - { triggers: ['change'], run: () => calls.push('incoming') }, - ], - }, - { - form: { - listenersMerge: 'append', - listeners: [ - { triggers: ['change'], run: () => calls.push('default') }, - ], - }, - }, - ) - - form.setFieldValue('name', 'initial') - expect(calls).toEqual(['default', 'incoming']) - - calls.length = 0 - form._update({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['change'], run: () => calls.push('updated') }], - }) - form.setFieldValue('name', 'updated') - expect(calls).toEqual(['default', 'updated']) - }) - }) - - describe('reset', () => { - it('resets form state', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - - field.handleChange('bye') - form.reset() - expect(form.state.values).toEqual({ name: '' }) - }) - - it('resets form state and updated default values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - - field.handleChange('bye') - form.reset({ name: 'reset default' }) - expect(form.state.values).toEqual({ name: 'reset default' }) - expect(form.defaultValues).toEqual({ name: 'reset default' }) - }) - - it('resets form state and does not update default values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - - field.handleChange('bye') - form.reset({ name: 'reset state' }, { updateDefaultValues: false }) - expect(form.state.values).toEqual({ name: 'reset state' }) - expect(form.defaultValues).toEqual({ name: '' }) - }) - - it('does not let a repeated _update default overwrite reset values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form._update({ defaultValues: { name: 'async' } }) - form.reset({ name: 'reset default' }) - form._update({ defaultValues: { name: 'async' } }) - - expect(form.state.values).toEqual({ name: 'reset default' }) - expect(form.defaultValues).toEqual({ name: 'reset default' }) - expect(form.state.isDefaultValue).toBe(true) - - form._update({ defaultValues: { name: 'new async' } }) - - expect(form.state.values).toEqual({ name: 'new async' }) - expect(form.defaultValues).toEqual({ name: 'new async' }) - expect(form.state.isDefaultValue).toBe(true) - }) - - it('resets meta and kills fields', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - run: () => 'Submit error', - triggers: [], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - field.handleChange('dirty') - - await form.handleSubmit() - form.reset() - - expect(form.state.values).toEqual({ name: '' }) - expect(form.state.isDirty).toBe(false) - expect(form.state.isTouched).toBe(false) - expect(form.state.errors).toEqual([]) - expect(form.state.submissionAttempts).toBe(0) - expect(form.getFieldMeta('name')).toBeUndefined() - }) - - it('cancels pending form validators on reset', async () => { - vi.useFakeTimers() - const validator = vi.fn(() => 'Late error') - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - triggers: ['change'], - triggerDebounceMs: 100, - run: validator, - }, - ], - }) - - const validatePromise = form.validate('change') - form.reset() - await vi.advanceTimersByTimeAsync(100) - const result = await validatePromise - - expect(result).toEqual([]) - expect(validator).not.toHaveBeenCalled() - expect(form.state.errors).toEqual([]) - }) - - it('cancels pending field listeners on reset', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { - triggers: ['change'], - triggerDebounceMs: 100, - run: listener, - }, - ], - }) - - field.handleChange('dirty') - form.reset() - await vi.advanceTimersByTimeAsync(100) - - expect(listener).not.toHaveBeenCalled() - }) - - it('ignores submit cleanup and submit errors after reset', async () => { - let finishSubmit!: () => void - const form = new InternalFormApi({ - defaultValues: { name: '' }, - onSubmit: ({ createValidationError }) => - new Promise((resolve) => { - finishSubmit = () => - resolve(createValidationError('Late submit error')) - }), - }) - - const submitPromise = form.handleSubmit() - await vi.waitFor(() => { - expect(finishSubmit).toBeTypeOf('function') - }) - - form.reset({ name: 'reset value' }, { updateDefaultValues: false }) - finishSubmit() - const result = await submitPromise - - expect(result).toEqual([]) - expect(form.state.values).toEqual({ name: 'reset value' }) - expect(form.state.errors).toEqual([]) - expect(form.state.isSubmitting).toBe(false) - expect(form.state.submissionAttempts).toBe(0) - }) - }) - - // field methods - - describe('resetField', () => { - it('should reset field', () => { - const form = new InternalFormApi({ defaultValues: { name: 'hi' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const validatorInstance = new InternalValidatorInstance({ - definition: { run: () => null, triggers: [] }, - owner: field, - scope: 'field', - }) - field.handleChange('bye') - field.handleBlur() - field._setMeta((prev) => ({ - ...prev, - isValidating: true, - _validationCount: 1, - _arrayVersion: 1, - _validationSourceErrors: new Map([ - [ - validatorInstance, - { errors: [{ message: 'Reset me' }], sourceEvent: 'change' }, - ], - ]), - })) - - expect(field._getBaseMeta()).not.toBe(defaultInternalBaseFieldMeta) - - form.resetField('name') - - expect(form.getFieldValue('name')).toEqual('hi') - expect(field._getBaseMeta()).toBe(defaultInternalBaseFieldMeta) - }) - - it('recursively resets descendant fields without replacing them', () => { - const form = new InternalFormApi({ - defaultValues: { person: { name: 'Alice' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'person' }) - const child = form._getOrCreateFieldApi({ name: 'person.name' }) - parent._register() - child._register() - - child.handleChange('Bob') - child.handleBlur() - - expect(parent._getBaseMeta()).not.toBe(defaultInternalBaseFieldMeta) - expect(child._getBaseMeta()).not.toBe(defaultInternalBaseFieldMeta) - - form.resetField('person') - - expect(form.getFieldValue('person')).toEqual({ name: 'Alice' }) - expect(form._tryGetFieldApi('person')).toBe(parent) - expect(form._tryGetFieldApi('person.name')).toBe(child) - expect(parent._isKilled).toBe(false) - expect(child._isKilled).toBe(false) - expect(parent._isMounted).toBe(true) - expect(child._isMounted).toBe(true) - expect(parent._getBaseMeta()).toBe(defaultInternalBaseFieldMeta) - expect(child._getBaseMeta()).toBe(defaultInternalBaseFieldMeta) - expect(form.state.isTouched).toBe(false) - }) - - it('deletes a field through an explicit field override', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - form.deleteField('name', { fieldApiOverride: field }) - - expect(form._tryGetFieldApi('name')).toBeNull() - }) - - it('ignores deleting a field that has not been created', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.deleteField('name') - - expect(form.state.values).toEqual({ name: '' }) - }) - }) - - describe('event error cleanup', () => { - it('clears form-level submit errors when any field changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError('Submission failed'), - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - expect.objectContaining({ message: 'Submission failed' }), - ]) - expect( - form._atoms.meta.formErrors - .get() - .validationSourceErrors?.get(form._onSubmitSource)?.errors, - ).toEqual([expect.objectContaining({ message: 'Submission failed' })]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - expect( - form._atoms.meta.formErrors - .get() - .validationSourceErrors?.get(form._onSubmitSource), - ).toBeUndefined() - }) - - it('clears form-level submit errors when any field blurs', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError('Submission failed'), - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - expect.objectContaining({ message: 'Submission failed' }), - ]) - - field.handleBlur() - - expect(form.state.errors).toEqual([]) - }) - - it('clears form-level submit-only validator errors when any field changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Submit-only validator failed', - triggers: [], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit-only validator failed' }, - ]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - }) - - it('clears form-level submit validator errors when the validator does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Submit or blur validator failed', - triggers: ['blur'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit or blur validator failed' }, - ]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - }) - - it('does not clear non-submit-sourced validator errors on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Blur validator failed', - triggers: ['blur'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.validate('blur') - expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) - }) - - it('does not event-clear submit errors when the validator runs on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: ({ event }) => - event === 'submit' - ? 'Submit validator failed' - : 'Change validator failed', - triggers: ['change'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit validator failed' }, - ]) - - field.handleChange('Alice') - - await vi.waitFor(() => { - expect(form.state.errors).toEqual([ - { message: 'Change validator failed' }, - ]) - }) - }) - - it('clears submit errors when the validator conditionally does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Submit validator failed', - triggers: [{ trigger: 'change', when: false }], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit validator failed' }, - ]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - }) - - it('only clears field-level submit errors for the field that changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError({ - fields: { - name: 'Name is required', - email: 'Email is required', - }, - }), - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const emailField = form._getOrCreateFieldApi({ name: 'email' }) - void nameField.atom - void emailField.atom - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - expect( - nameField - ._getBaseMeta() - ._validationSourceErrors?.get(form._onSubmitSource)?.errors, - ).toEqual([{ message: 'Name is required' }]) - expect(form._onSubmitSource.errorTargets).toEqual( - new Set([nameField, emailField]), - ) - - nameField.handleChange('Alice') - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - expect(form._onSubmitSource.errorTargets).toEqual(new Set([emailField])) - }) - - it('only clears field-level submit errors for the field that blurs', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError({ - fields: { - name: 'Name is required', - email: 'Email is required', - }, - }), - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const emailField = form._getOrCreateFieldApi({ name: 'email' }) - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - - nameField.handleBlur() - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - }) - - it('clears field-level submit-only validator errors for the field that changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - }) - const nameField = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Name is required', - triggers: [], - }, - ], - }) - const emailField = form._getOrCreateFieldApi({ - name: 'email', - validators: [ - { - run: () => 'Email is required', - triggers: [], - }, - ], - }) - void nameField.atom - void emailField.atom - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - - nameField.handleChange('Alice') - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - }) - - it('clears field-level submit validator errors when the validator does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - }) - const nameField = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Name is required', - triggers: ['blur'], - }, - ], - }) - const emailField = form._getOrCreateFieldApi({ - name: 'email', - validators: [ - { - run: () => 'Email is required', - triggers: ['blur'], - }, - ], - }) - void nameField.atom - void emailField.atom - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - - nameField.handleChange('Alice') - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - }) - - it('does not clear non-submit-sourced field validator errors on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Name is required', - triggers: ['blur'], - }, - ], - }) - void field.atom - - await field._runFieldValidation('blur') - expect(field.errors).toEqual([{ message: 'Name is required' }]) - - field.handleChange('Alice') - - expect(field.errors).toEqual([{ message: 'Name is required' }]) - }) - - it('does not event-clear field validator errors when the validator runs on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: ({ event }: { event: string }) => - event === 'submit' - ? 'Submit field validator failed' - : 'Change field validator failed', - triggers: ['change'], - }, - ], - }) - void field.atom - - await form.handleSubmit() - expect(field.errors).toEqual([ - { message: 'Submit field validator failed' }, - ]) - - field.handleChange('Alice') - - await vi.waitFor(() => { - expect(field.errors).toEqual([ - { message: 'Change field validator failed' }, - ]) - }) - }) - - it('clears field submit errors when the validator conditionally does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Submit field validator failed', - triggers: [{ trigger: 'change', when: false }], - }, - ], - }) - void field.atom - - await form.handleSubmit() - expect(field.errors).toEqual([ - { message: 'Submit field validator failed' }, - ]) - - field.handleChange('Alice') - - expect(field.errors).toEqual([]) - }) - }) - - describe('deleteField ', () => { - it('unmounts the field atom', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - expect(field._isMounted).toBe(true) - form.deleteField('name', { fieldApiOverride: field }) - expect(field._isMounted).toBe(false) - }) - - it('also unmounts child field atoms', () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - void parent.atom - void child.atom - form.deleteField('a', { fieldApiOverride: parent }) - expect(parent._isMounted).toBe(false) - expect(child._isMounted).toBe(false) - }) - }) -}) diff --git a/packages/form-core/tests/devtoolsBridge.lib.spec.ts b/packages/form-core/tests/devtoolsBridge.lib.spec.ts new file mode 100644 index 0000000000..a8bec0aad4 --- /dev/null +++ b/packages/form-core/tests/devtoolsBridge.lib.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../src/FormApi/FormApi.lib' +import { installDevtoolsBridge } from '../src/devtoolsBridge.lib' + +describe('installDevtoolsBridge', () => { + it('receives form mount, update, and unmount notifications', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + const mountForm = vi.fn() + const unmountForm = vi.fn() + const updateForm = vi.fn() + const uninstall = installDevtoolsBridge({ + mountForm, + unmountForm, + updateForm, + }) + onTestFinished(uninstall) + + const unmount = form.mount() + onTestFinished(unmount) + expect(mountForm).toHaveBeenCalledWith(form) + + form._update({ + formId: 'renamed-profile-form', + defaultValues: { name: '' }, + }) + + expect(updateForm).toHaveBeenCalledWith(form) + + unmount() + + expect(unmountForm).toHaveBeenCalledWith(form) + }) + + it('stops lifecycle notifications after the bridge is uninstalled', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + const mountForm = vi.fn() + const unmountForm = vi.fn() + const updateForm = vi.fn() + const uninstall = installDevtoolsBridge({ + mountForm, + unmountForm, + updateForm, + }) + onTestFinished(uninstall) + + const unmount = form.mount() + onTestFinished(unmount) + expect(mountForm).toHaveBeenCalledWith(form) + mountForm.mockClear() + + uninstall() + + const unmountAfterUninstall = form.mount() + onTestFinished(unmountAfterUninstall) + form._update({ + formId: 'renamed-profile-form', + defaultValues: { name: '' }, + }) + unmount() + unmountAfterUninstall() + + expect(mountForm).not.toHaveBeenCalled() + expect(updateForm).not.toHaveBeenCalled() + expect(unmountForm).not.toHaveBeenCalled() + }) +}) From a3ce05426d4a2052de8432a5d87887ce5947fdc0 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:07 +0200 Subject: [PATCH 12/43] refactor: fix tsconfig and internal form options --- packages/form-core/src/FormApi/FormApi.lib.ts | 23 +++++++++++++++++-- .../tests/FormApi/FormApi.lib.spec.ts | 2 -- tsconfig.json | 2 +- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 6d7e93c7e9..35cef0875a 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -84,14 +84,21 @@ import type { ValidationIssue, } from '../validation/state.public' import type { FormOptions } from './formOptions.public' +import type { + FormOptionsOnSubmitFn, + FormOptionsOnSubmitInvalidFn, +} from './formSubmit.public' +import type { FormValidator } from '../validation/formValidators.public' import type { ServerFormState } from './formServerState.public' import type { ClientSideEvents, DynamicValidatorTriggers, + ValidatorTriggers, } from '../events.public' import type { AnyFormListener, FormListenerTriggers, + FormListeners, } from './formListeners.public' export interface FormMetaAtoms { @@ -152,7 +159,19 @@ export type InternalFormValidatorInstance = InternalValidatorInstance< AnyInternalFieldApi > -type FormOptionsOfType = FormOptions +// Funky form options are cool and all, but just using `any` killed our type inference internally. +// This is hacky, but it should do. +type FormOptionsOfType = Omit< + FormOptions, + 'validators' | 'listeners' | 'onSubmit' | 'onSubmitInvalid' +> & { + validators?: ReadonlyArray< + FormValidator, ValidatorTriggers | boolean> + > + listeners?: FormListeners, any> + onSubmit?: FormOptionsOnSubmitFn, any, any> + onSubmitInvalid?: FormOptionsOnSubmitInvalidFn, any> +} type InternalFormOptions = FormOptionsOfType & { formId: string } @@ -409,7 +428,7 @@ export class InternalFormApi implements FormApi< this._notifyFormListener('reset', null) } - _update(options: FormOptions) { + _update(options: FormOptionsOfType) { const resolvedOptions = resolveDefaultOptions( options, this._defaultOptions?.form, diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index 224dd14979..60c1c5943b 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -1,5 +1,3 @@ -/// - import { describe, expect, it, vi } from 'vitest' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' import { formOptions } from '../../src/FormApi/formOptions.public' diff --git a/tsconfig.json b/tsconfig.json index 9ad8c64133..c495877a1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["DOM", "DOM.Iterable", "ES2022"], + "lib": ["DOM", "DOM.Iterable", "ES2022", "ES2024.Promise"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, From 05a9ec30f333ca838fbd28e3781efe99d707e57e Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:33:27 +0200 Subject: [PATCH 13/43] chore: migrate unit tests to new system part 2 --- .../tests/FieldApi/FieldApi.lib.spec.ts | 642 +++++++++++++ .../tests/FieldApi/Lifecycle.spec.ts | 875 ------------------ .../tests/FormApi/FormApi.lib.spec.ts | 95 ++ .../tests/devtoolsBridge.lib.spec.ts | 193 ++++ 4 files changed, 930 insertions(+), 875 deletions(-) delete mode 100644 packages/form-core/tests/FieldApi/Lifecycle.spec.ts diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index 08be3d17e4..0cf633718e 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -1,6 +1,648 @@ import { describe, expect, it, vi } from 'vitest' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +describe('getOrCreateFieldApi', () => { + it('applies validators to a field whose value was set before registration', async () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + + form.setFieldValue('name', 'before render') + expect(form.getFieldMeta('name')).toMatchObject({ + isTouched: true, + isDirty: true, + }) + expect(form.state.isValid).toBe(true) + + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is invalid', triggers: [] }], + }) + field._register() + + expect(form.state.isValid).toBe(true) + expect(field.value).toBe('before render') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + }) + + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Name is invalid' }]) + expect(form.state.isValid).toBe(false) + }) +}) + +describe('InternalFieldApi - _update', () => { + it('retains default listeners before incoming listeners during construction and updates', () => { + const defaultListener = vi.fn() + const incomingListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi( + { defaultValues: { name: '' } }, + { + field: { + listenersMerge: 'append', + listeners: [{ triggers: ['change'], run: defaultListener }], + }, + }, + ) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['change'], run: incomingListener }], + }) + + field.handleChange('initial') + + expect(defaultListener).toHaveBeenCalledOnce() + expect(incomingListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(incomingListener) + expect(incomingListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + + defaultListener.mockClear() + incomingListener.mockClear() + field._update({ + listeners: [{ triggers: ['change'], run: updatedListener }], + }) + field.handleChange('updated') + + expect(defaultListener).toHaveBeenCalledOnce() + expect(updatedListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(updatedListener) + expect(updatedListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'updated' }), + ) + expect(incomingListener).not.toHaveBeenCalled() + }) + + it('applies the default error boundary only after configuration and respects an explicit override', async () => { + // This kinda masks a problem where an immediate mount validation on the form can 100% NOT respect + // a field component with `errorBoundary`. Too bad, I assume this won't happen much. + const form = new InternalFormApi( + { + defaultValues: { user: { name: '' } }, + validators: [ + { + triggers: ['change'], + run: ({ createErrorMap }) => + createErrorMap({ fields: { 'user.name': 'Name is required' } }), + }, + ], + }, + { field: { errorBoundary: true } }, + ) + const parent = form._getOrCreateFieldApi({ name: 'user' }, 'internal') + const child = form._getOrCreateFieldApi({ name: 'user.name' }, 'internal') + parent._register() + child._register() + + await form.validate('change') + expect(child.errors).toEqual([{ message: 'Name is required' }]) + expect(parent.errors).toEqual([]) + + form._getOrCreateFieldApi({ name: 'user' }) + + await form.validate('change') + expect(parent.errors).toEqual([{ message: 'Name is required' }]) + expect(child.errors).toEqual([]) + + parent._update({ errorBoundary: false }) + + await form.validate('change') + expect(child.errors).toEqual([{ message: 'Name is required' }]) + expect(parent.errors).toEqual([]) + }) + + it('uses updated validators, retains omitted validators, and removes validators with an empty array', async ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Initial error', triggers: [] }], + }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Initial error' }]) + + field._update({ + validators: [{ run: () => 'Updated error', triggers: [] }], + }) + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Updated error' }]) + + field._update({}) + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Updated error' }]) + + field._update({ validators: [] }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the validator array should not change', + ), + ) + await expect(form.handleSubmit()).resolves.toEqual([]) + expect(field.errors).toEqual([]) + expect(form.state.canSubmit).toBe(true) + }) + + it('uses updated listener callbacks and triggers, retains omitted listeners, and removes listeners with an empty array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const initialListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['change'], run: initialListener }], + }) + + field.handleChange('initial') + expect(initialListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + initialListener.mockClear() + + field._update({ + listeners: [{ triggers: ['blur'], run: updatedListener }], + }) + field.handleChange('updated') + + expect(initialListener).not.toHaveBeenCalled() + expect(updatedListener).not.toHaveBeenCalled() + + field.handleBlur() + expect(updatedListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'updated' }), + ) + updatedListener.mockClear() + + field._update({}) + field.handleBlur() + expect(updatedListener).toHaveBeenCalledOnce() + updatedListener.mockClear() + + field._update({ listeners: [] }) + field.handleBlur() + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('length of the listener array should not change'), + ) + expect(updatedListener).not.toHaveBeenCalled() + }) +}) + +describe('InternalFieldApi - atom', () => { + it('exposes the default value with untouched, valid state', () => { + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + const state = field.atom.get() + + expect(state).toMatchObject({ + value: 'Alice', + meta: { + errors: [], + isTouched: false, + isDirty: false, + isValid: true, + isValidating: false, + }, + }) + }) +}) + +describe('InternalFieldApi - reset', () => { + it('aborts active validation, ignores its late result, and allows validation to run again', async ({ + onTestFinished, + }) => { + const request = Promise.withResolvers() + onTestFinished(() => request.resolve('Late error')) + const onAbort = vi.fn() + const validator = vi.fn(() => request.promise) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: [], + run: ({ signal }: { signal: AbortSignal }) => { + signal.addEventListener('abort', onAbort, { once: true }) + return validator() + }, + }, + ], + }) + field._register() + + const submission = form.handleSubmit() + await expect.poll(() => validator).toHaveBeenCalledOnce() + expect(field.meta.isValidating).toBe(true) + + field.reset() + + expect(onAbort).toHaveBeenCalledOnce() + await expect(submission).resolves.toEqual([]) + request.resolve('Late error') + await request.promise + + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(false) + + validator.mockResolvedValue('Current error') + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Current error' }]) + }) + + it('cancels a pending listener callback and allows subsequent changes to notify it', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { run: listener, triggers: ['change'], triggerDebounceMs: 100 }, + ], + }) + + field.handleChange('initial') + await vi.advanceTimersByTimeAsync(100) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + listener.mockClear() + + field.handleChange('pending') + field.reset() + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + expect(field.value).toBe('') + + field.handleChange('after reset') + await vi.advanceTimersByTimeAsync(100) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'after reset' }), + ) + }) +}) + +describe('InternalFieldApi - _kill', () => { + it('does not run an unmounted field validator when a watched field is deleted and recreated', async () => { + vi.useFakeTimers() + const validator = vi.fn(() => null) + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { triggers: ['change'], watchFields: ['source'], run: validator }, + ], + }) + source._register() + const unregisterTarget = target._register() + + source.handleChange('first') + await vi.runOnlyPendingTimersAsync() + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target', event: 'change' }), + ) + validator.mockClear() + + unregisterTarget() + await vi.runOnlyPendingTimersAsync() + form.deleteField('source') + + form.setFieldValue('source', 'replacement') + await vi.runOnlyPendingTimersAsync() + + expect(validator).not.toHaveBeenCalled() + expect(form.getFieldValue('target')).toBe('target') + expect(form.state.isValid).toBe(true) + }) + + it('keeps a registered watcher usable after deleting its source and detaches the old dependency', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: listener }, + ], + }) + source._register() + target._register() + + source.handleChange('first') + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target' }), + ) + listener.mockClear() + + form.deleteField('source') + form.setFieldValue('source', 'replacement') + + expect(listener).not.toHaveBeenCalled() + + target.handleChange('direct edit') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'direct edit' }), + ) + expect(form.getFieldValue('target')).toBe('direct edit') + }) + + it('ignores array edits made through a deleted field', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + field._register() + field.pushValue('active') + + const beforeDelete = form.getFieldValue('items') + expect(beforeDelete).toEqual(['a', 'b', 'active']) + + form.deleteField('items') + + field.swapValues(0, 1) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.moveValue(0, 1) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.clearValues() + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.pushValue('ignored') + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.insertValue(1, 'ignored') + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.removeValue(0) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.filterValues(() => false) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + }) + + it('ignores blur events made through a deleted field', () => { + const onBlur = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['blur'], run: onBlur }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + field.handleBlur() + expect(onBlur).toHaveBeenCalledWith( + expect.objectContaining({ triggerFieldApi: field }), + ) + onBlur.mockClear() + + form.deleteField('name') + field.handleBlur() + + expect(onBlur).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + }) + + it('aborts active validation when deleted and ignores late results and subsequent interactions', async ({ + onTestFinished, + }) => { + const request = Promise.withResolvers() + onTestFinished(() => request.resolve('Late error')) + const onAbort = vi.fn() + const validator = vi.fn(() => request.promise) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ signal }: { signal: AbortSignal }) => { + signal.addEventListener('abort', onAbort, { once: true }) + return validator() + }, + }, + ], + }) + field._register() + + const submission = form.handleSubmit() + await expect.poll(() => validator).toHaveBeenCalledOnce() + expect(field.meta.isValidating).toBe(true) + + form.deleteField('name') + + expect(onAbort).toHaveBeenCalledOnce() + await expect(submission).resolves.toEqual([]) + request.resolve('Late error') + await request.promise + validator.mockClear() + + field.handleChange('ignored') + await expect(form.handleSubmit()).resolves.toEqual([]) + + expect(validator).not.toHaveBeenCalled() + expect(form.getFieldValue('name')).toBe('') + expect(form.state.isValid).toBe(true) + expect(form.state.isValidating).toBe(false) + expect(field.errors).toEqual([]) + }) + + it('cancels pending listener callbacks when deleted and ignores subsequent changes', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { run: listener, triggers: ['change'], triggerDebounceMs: 100 }, + ], + }) + + field.handleChange('initial') + await vi.advanceTimersByTimeAsync(100) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + listener.mockClear() + + field.handleChange('pending') + form.deleteField('name') + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + + field.handleChange('ignored') + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldValue('name')).toBe('pending') + }) +}) + +describe('InternalFieldApi - _register', () => { + it('ignores registration and cleanup callbacks for a deleted field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['mount', 'unmount'], run: listener }], + }) + const unregister = field._register() + expect(listener).toHaveBeenCalledOnce() + listener.mockClear() + + form.deleteField('name') + const unregisterAfterDeletion = field._register() + unregisterAfterDeletion() + unregister() + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + }) +}) + +describe('InternalFieldApi - _unregister', () => { + it('continues validating a field while another consumer remains registered', async () => { + vi.useFakeTimers() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ triggers: [], run: () => 'Name is required' }], + }) + const unregisterFirst = field._register() + const unregisterSecond = field._register() + + unregisterFirst() + await vi.runOnlyPendingTimersAsync() + + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.canSubmit).toBe(false) + + unregisterSecond() + await vi.runOnlyPendingTimersAsync() + }) + + it('preserves nested default values and field interactions after unmounting and remounting', async () => { + vi.useFakeTimers() + const form = new InternalFormApi({ + defaultValues: { user: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const child = form._getOrCreateFieldApi({ name: 'user.name' }) + const unregisterParent = parent._register() + const unregisterChild = child._register() + expect(parent.value).toEqual({ name: 'Alice' }) + expect(child.value).toBe('Alice') + + unregisterChild() + unregisterParent() + await vi.runOnlyPendingTimersAsync() + + const remountedParent = form._getOrCreateFieldApi({ name: 'user' }) + const remountedChild = form._getOrCreateFieldApi({ name: 'user.name' }) + remountedParent._register() + remountedChild._register() + + expect(remountedChild.value).toBe('Alice') + expect(remountedChild.meta).toMatchObject({ + isTouched: false, + isDirty: false, + errors: [], + }) + + remountedChild.handleChange('Bob') + + expect(form.state.values).toEqual({ user: { name: 'Bob' } }) + expect(remountedParent.value).toEqual({ name: 'Bob' }) + expect(remountedParent.meta.isTouched).toBe(true) + }) + + it('preserves edited values and interaction state after unmounting and remounting', async () => { + vi.useFakeTimers() + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const unregister = field._register() + field.handleChange('Bob') + field.handleBlur() + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: true, + }) + + unregister() + await vi.runOnlyPendingTimersAsync() + + const remounted = form._getOrCreateFieldApi({ name: 'name' }) + remounted._register() + + expect(remounted.value).toBe('Bob') + expect(remounted.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: true, + }) + expect(form.state.isTouched).toBe(true) + }) + + it('keeps linked validation working after both field components unregister', async ({ + annotate, + }) => { + await annotate( + 'https://github.com/TanStack/form/issues/2296', + 'GitHub RFC link. Its decision affects the outcome of this test.', + ) + vi.useFakeTimers() + const validator = vi.fn(() => 'Target is invalid') + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { triggers: ['change'], watchFields: ['source'], run: validator }, + ], + }) + const unregisterSource = source._register() + const unregisterTarget = target._register() + + unregisterSource() + unregisterTarget() + await vi.runOnlyPendingTimersAsync() + + form.setFieldValue('source', 'changed') + await vi.runOnlyPendingTimersAsync() + + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target', event: 'change' }), + ) + expect(form.getFieldMeta('target')?.errors).toEqual([ + { message: 'Target is invalid' }, + ]) + expect(form.state.isValid).toBe(false) + }) +}) + describe('InternalFieldApi - _clearEventErrors', () => { it('clears submit-only validator errors only for the field that changes', async () => { const form = new InternalFormApi({ diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts deleted file mode 100644 index b2e02dcdba..0000000000 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ /dev/null @@ -1,875 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - defaultFieldMeta, - defaultInternalBaseFieldMeta, -} from '../../src/FieldApi/fieldState.lib' -import { canPruneField } from '../../src/FieldApi/fieldTree.lib' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' -import { validationSourceScopes } from '../../src/ValidationSourceInstance.lib' -import { installDevtoolsBridge } from '../../src/devtoolsBridge.lib' - -describe('field - lifecycle', () => { - describe('default options', () => { - it('resolves defaults during construction and updates', () => { - const calls: Array = [] - const form = new InternalFormApi( - { defaultValues: { name: '', internal: '' } }, - { - field: { - errorBoundary: true, - listenersMerge: 'append', - listeners: [ - { triggers: ['change'], run: () => calls.push('default') }, - ], - }, - }, - ) - const internalField = form._getOrCreateFieldApi( - { name: 'internal' }, - 'internal', - ) - const field = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { triggers: ['change'], run: () => calls.push('incoming') }, - ], - }) - - expect(internalField._errorBoundary).toBe(false) - field.handleChange('initial') - expect(field._errorBoundary).toBe(true) - expect(calls).toEqual(['default', 'incoming']) - - calls.length = 0 - field._update({ - errorBoundary: false, - listeners: [{ triggers: ['change'], run: () => calls.push('updated') }], - }) - field.handleChange('updated') - expect(field._errorBoundary).toBe(false) - expect(calls).toEqual(['default', 'updated']) - }) - - it('configures a newly created field once', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const validator = { run: () => null, triggers: [] } - - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [validator], - }) - - expect(field._fieldOptionsInitialized).toBe(true) - expect(field._validatorInstances?.[0]?.definition).toBe(validator) - expect(field._validatorInstances?.[0]?.revision).toBe(0) - }) - - it('updates an internal field once when it is first configured', () => { - const form = new InternalFormApi( - { defaultValues: { x: '' } }, - { field: { errorBoundary: true } }, - ) - const field = form._getOrCreateFieldApi({ name: 'x' }, 'internal') - const update = vi.spyOn(field, '_update') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const initialValidator = { run: () => null, triggers: [] } - - expect(field._errorBoundary).toBe(false) - - const configuredField = form._getOrCreateFieldApi({ - name: 'x', - validators: [initialValidator], - }) - - expect(configuredField).toBe(field) - expect(field._fieldOptionsInitialized).toBe(true) - expect(field._errorBoundary).toBe(true) - expect(update).toHaveBeenCalledOnce() - expect(field._validatorInstances?.[0]?.definition).toBe(initialValidator) - expect(field._validatorInstances?.[0]?.revision).toBe(0) - expect(warn).not.toHaveBeenCalled() - - update.mockClear() - const nextValidator = { run: () => null, triggers: [] } - form._getOrCreateFieldApi({ - name: 'x', - validators: [nextValidator], - }) - - expect(update).not.toHaveBeenCalled() - expect(field._validatorInstances?.[0]?.definition).toBe(initialValidator) - - field._update({ validators: [nextValidator] }) - - expect(field._validatorInstances?.[0]?.definition).toBe(nextValidator) - expect(field._validatorInstances?.[0]?.revision).toBe(1) - expect(warn).not.toHaveBeenCalled() - - update.mockRestore() - warn.mockRestore() - }) - }) - - describe('_isMounted and atom', () => { - it('is false before the atom is accessed', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - expect(field._isMounted).toBe(false) - }) - - it('is true after registering', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - field._register() - expect(field._isMounted).toBe(true) - }) - - it('atom returns consistent state', () => { - const form = new InternalFormApi({ defaultValues: { x: 'hello' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - const state = field.atom.get() - expect(state.value).toBe('hello') - expect(state.meta).toMatchObject(defaultFieldMeta) - }) - }) - - describe('validator instances', () => { - it('keeps instances stable by slot and distinguishes omitted validators from an empty array', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const firstDefinition = { run: () => null, triggers: [] } - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [firstDefinition], - }) - const instance = field._validatorInstances?.[0] - const initialRevision = instance?.revision - const nextDefinition = { run: () => null, triggers: [] } - - field._update({ validators: [nextDefinition] }) - - expect(field._validatorInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(field) - expect(instance?.scope).toBe(validationSourceScopes.field) - expect(instance?.revision).toBe((initialRevision ?? 0) + 1) - - field._update({}) - expect(field._validatorInstances?.[0]).toBe(instance) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - field._update({ validators: [] }) - expect(field._validatorInstances).toBeNull() - expect(instance?.disposed).toBe(true) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('resets runtime on field reset and disposes instances on kill', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [{ run: () => null, triggers: [] }], - }) - const instance = field._validatorInstances?.[0] - const abortController = new AbortController() - instance?.setAbortController(abortController) - instance?.setOutput({ - output: 'output', - hasOutput: true, - }) - - field.reset() - - expect(field._validatorInstances?.[0]).toBe(instance) - expect(abortController.signal.aborted).toBe(true) - expect(instance?.hasOutput).toBe(false) - expect(instance?.disposed).toBe(false) - - field._kill() - - expect(field._validatorInstances).toBeNull() - expect(instance?.disposed).toBe(true) - }) - }) - - describe('listener instances', () => { - it('keeps instances stable by slot and distinguishes omitted listeners from an empty array', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const firstDefinition = { - run: () => {}, - triggers: ['change'] as Array<'change'>, - } - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [firstDefinition], - }) - const instance = field._listenerInstances?.[0] - const initialRevision = instance?.revision - const nextDefinition = { - run: () => {}, - triggers: ['blur'] as Array<'blur'>, - } - - field._update({ listeners: [nextDefinition] }) - - expect(field._listenerInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(field) - expect(instance?.index).toBe(0) - expect(instance?.revision).toBe((initialRevision ?? 0) + 1) - - field._update({}) - expect(field._listenerInstances?.[0]).toBe(instance) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - field._update({ listeners: [] }) - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'length of the listener array should not change', - ), - ) - expect(field._listenerInstances).toBeNull() - expect(instance?.disposed).toBe(true) - warn.mockRestore() - }) - - it('resets runtime on field reset and disposes instances on kill', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [ - { - run: listener, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ], - }) - const instance = field._listenerInstances?.[0] - - field.handleChange('pending') - field.reset() - await vi.advanceTimersByTimeAsync(100) - - expect(listener).not.toHaveBeenCalled() - expect(field._listenerInstances?.[0]).toBe(instance) - expect(instance?.debouncer).toBeNull() - expect(instance?.disposed).toBe(false) - - field._kill() - - expect(field._listenerInstances).toBeNull() - expect(instance?.disposed).toBe(true) - vi.useRealTimers() - }) - }) - - describe('devtools bridge notifications', () => { - it('notifies field mount and final unmount transitions only', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const mountField = vi.fn() - const unmountField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - mountField, - unmountField, - }) - - try { - const unregister1 = field._register() - const unregister2 = field._register() - - expect(mountField).toHaveBeenCalledOnce() - expect(mountField).toHaveBeenCalledWith(field) - - unregister1() - expect(unmountField).not.toHaveBeenCalled() - - unregister2() - expect(unmountField).toHaveBeenCalledOnce() - expect(unmountField).toHaveBeenCalledWith(field, 'name') - } finally { - uninstallBridge() - } - }) - - it('notifies field moves with the previous path', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const moveField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ moveField }) - - try { - form.swapFieldValues('items', 0, 1) - - expect(moveField).toHaveBeenCalledWith(field0, 'items[0]') - expect(moveField).toHaveBeenCalledWith(field1, 'items[1]') - expect(field0.name).toBe('items[1]') - expect(field1.name).toBe('items[0]') - } finally { - uninstallBridge() - } - }) - - it('notifies field meta updates for changed fields and their parents', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: '' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'user' }) - const child = form._getOrCreateFieldApi({ name: 'user.name' }) - const updateField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ updateField }) - - try { - child.handleChange('changed') - - expect(child.meta.isDirty).toBe(true) - expect(parent.meta.isDirty).toBe(true) - expect(updateField).toHaveBeenCalledWith(child) - expect(updateField).toHaveBeenCalledWith(parent) - } finally { - uninstallBridge() - } - }) - - it('notifies field value updates for the field and its parents even when summary meta is unchanged', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: '' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'user' }) - const field = form._getOrCreateFieldApi({ name: 'user.name' }) - const updateField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ updateField }) - - try { - field.handleChange('first') - updateField.mockClear() - - field.handleChange('second') - - expect(field.meta.isDirty).toBe(true) - expect(updateField).toHaveBeenCalledWith(field) - expect(updateField).toHaveBeenCalledWith(parent) - } finally { - uninstallBridge() - } - }) - - it('notifies field and default value updates that bypass field change events', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const updateField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ updateField }) - - try { - field.handleChange('changed') - updateField.mockClear() - - form.resetField('name') - expect(updateField).toHaveBeenCalledWith(field) - - updateField.mockClear() - form._update({ defaultValues: { name: 'new default' } }) - expect(updateField).toHaveBeenCalledWith(field) - } finally { - uninstallBridge() - } - }) - - it('notifies removed field subtrees with previous paths', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: '' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'user' }) - const child = form._getOrCreateFieldApi({ name: 'user.name' }) - const removeFieldSubtree = vi.fn() - const uninstallBridge = installDevtoolsBridge({ removeFieldSubtree }) - - try { - parent._kill() - - expect(removeFieldSubtree).toHaveBeenCalledOnce() - expect(removeFieldSubtree).toHaveBeenCalledWith(form, [ - { field: parent, previousPath: 'user' }, - { field: child, previousPath: 'user.name' }, - ]) - } finally { - uninstallBridge() - } - }) - - it('notifies semantic field topology changes', () => { - const form = new InternalFormApi({ - defaultValues: { parent: {}, source: '', target: '' }, - }) - form._getOrCreateFieldApi({ name: 'parent' }) - const source = form._getOrCreateFieldApi({ name: 'source' }) - const target = form._getOrCreateFieldApi({ name: 'target' }) - const fieldAdded = vi.fn() - const fieldDependenciesChanged = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - fieldAdded, - fieldDependenciesChanged, - }) - - try { - const child = form._getOrCreateFieldApi({ name: 'parent.child' }) - - expect(fieldAdded).toHaveBeenCalledWith(child) - expect(form._tryGetFieldApi('parent.child')).toBe(child) - - target._update({ - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => {}, - }, - ], - }) - const listenerInstance = target._listenerInstances![0]! - - expect(fieldDependenciesChanged).toHaveBeenCalledWith([ - { - kind: 'listener', - sourceField: source, - watchingField: target, - listenerInstance, - }, - ]) - - fieldDependenciesChanged.mockClear() - target._kill() - - expect(fieldDependenciesChanged).toHaveBeenCalledWith([ - { - kind: 'listener', - sourceField: source, - watchingField: target, - listenerInstance, - }, - ]) - expect(source._watchingListenerFields).toBeNull() - } finally { - uninstallBridge() - } - }) - }) - - describe('_kill', () => { - it('unmounts the field atom', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - field._register() - expect(field._isMounted).toBe(true) - field._kill() - expect(field._isMounted).toBe(false) - expect(field._isKilled).toBe(true) - }) - - it('also kills child fields', () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - void parent.atom - void child.atom - parent._kill() - expect(parent._isMounted).toBe(false) - expect(child._isMounted).toBe(false) - expect(parent._isKilled).toBe(true) - expect(child._isKilled).toBe(true) - }) - - it('can notify a listener event before killing the field subtree', () => { - const parentListener = vi.fn() - const childListener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ - name: 'a', - listeners: [{ triggers: ['reset'], run: parentListener }], - }) - form._getOrCreateFieldApi({ - name: 'a.b', - listeners: [{ triggers: ['reset'], run: childListener }], - }) - - parent._kill({ listenerEvent: 'reset' }) - - expect(parentListener).toHaveBeenCalledOnce() - expect(childListener).toHaveBeenCalledOnce() - }) - - it('ignores validation started after a field is killed', async () => { - const validator = vi.fn(() => ({ message: 'Too late' })) - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [{ run: validator, triggers: [] }], - }) - - field._kill() - - const result = await field._runFieldValidation('submit') - - expect(validator).not.toHaveBeenCalled() - expect(result.results).toEqual([]) - expect(field.errors).toEqual([]) - }) - - it('ignores validation results that arrive after a field is killed', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [{ run: () => null, triggers: [] }], - }) - const validatorInstance = field._validatorInstances![0]! - - field._kill() - field._processValidationResult( - { - validatorInstance, - result: { message: 'Too late' }, - output: null, - }, - 'submit', - ) - - expect(field.errors).toEqual([]) - }) - - it('ignores stale field writes after a field is killed', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - field._kill() - field.handleChange('ignored') - - expect(form.getFieldValue('x')).toBe('') - }) - - it('ignores stale array and blur methods after a field is killed', () => { - const formListener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - listeners: [{ triggers: ['blur'], run: formListener }], - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field._kill() - field.swapValues(0, 1) - field.moveValue(0, 1) - field.clearValues() - field.pushValue('c') - field.insertValue(1, 'x') - field.removeValue(0) - field.filterValues(() => false) - field.handleBlur() - - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - expect(formListener).not.toHaveBeenCalled() - }) - - it('returns a no-op unregister callback for a killed field', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - field._kill() - const unregister = field._register() - unregister() - field._unregister() - - expect(field._isMounted).toBe(false) - }) - - it('does not prune killed fields or fields with retained meta state', () => { - const cases = [ - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, isDirty: true })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, isBlurred: true })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, isValidating: true })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, _validationCount: 1 })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, _arrayVersion: 1 })), - ] - - cases.forEach((markRetainedMeta, index) => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: `x${index}` }) - markRetainedMeta(field) - - expect(canPruneField(field)).toBe(false) - }) - - const form = new InternalFormApi({ defaultValues: { killed: '' } }) - const killedField = form._getOrCreateFieldApi({ name: 'killed' }) - killedField._kill() - - expect(canPruneField(killedField)).toBe(false) - }) - - it('removes killed fields from form-level routed error bookkeeping', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - triggers: [], - run: () => ({ - fields: { - name: 'Name is required', - }, - }), - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.validate('submit') - expect(field.errors).toEqual([{ message: 'Name is required' }]) - expect(form.state.canSubmit).toBe(false) - - field._kill() - - expect(form.state.canSubmit).toBe(true) - expect(form._validatorInstances?.[0]?.errorTargets).toBeNull() - }) - - it('removes killed fields from onSubmit routed error bookkeeping', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - onSubmit: ({ createValidationError }) => - createValidationError({ fields: { name: 'Name is required' } }), - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form._onSubmitSource.errorTargets).toEqual(new Set([field])) - - field._kill() - - expect(form._onSubmitSource.errorTargets).toBeNull() - }) - - it('preserves routed errors for fields outside the killed subtree', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', other: '' }, - validators: [ - { - triggers: [], - run: () => ({ - fields: { - name: 'Name is required', - }, - }), - }, - ], - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const otherField = form._getOrCreateFieldApi({ name: 'other' }) - - await form.validate('submit') - otherField._kill() - - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(form.state.canSubmit).toBe(false) - }) - - it('does not prune either endpoint while watched validators are attached', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => null, - }, - ], - }) - - expect(canPruneField(sourceField)).toBe(false) - expect(canPruneField(targetField)).toBe(false) - }) - - it('prunes an unmounted watcher after its final source is killed', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => null, - }, - ], - }) - - sourceField._kill() - - expect(form._tryGetFieldApi('source')).toBeNull() - expect(form._tryGetFieldApi('target')).toBeNull() - }) - - it('keeps a mounted watcher after its source is killed', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => {}, - }, - ], - }) - const unregisterTarget = targetField._register() - - try { - sourceField._kill() - - expect(form._tryGetFieldApi('target')).toBe(targetField) - expect( - targetField._listenerInstances?.[0]?.resolvedWatchFields, - ).toBeNull() - } finally { - unregisterTarget() - } - }) - }) - - describe('_unregister', () => { - it('unmounts when no registers remain', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._isMounted).toBe(true) - - unregister() - expect(field._isMounted).toBe(false) - }) - - it('cleans up the atom cache on unmount', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._atoms.store).toBeDefined() - - unregister() - - await vi.waitFor(() => { - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('cleans up atoms on unmount when meta was not modified', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._atoms.meta).toBeDefined() - expect(field._atoms.meta?.get()).toBe(defaultInternalBaseFieldMeta) - - unregister() - - await vi.waitFor(() => { - expect(field._atoms.meta).toBeUndefined() - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('keeps meta atom on unmount when meta was modified', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._atoms.meta).toBeDefined() - expect(field._atoms.meta?.get()).toBe(defaultInternalBaseFieldMeta) - - field.handleChange('New value') - - unregister() - - await vi.waitFor(() => { - expect(field._atoms.meta).toBeDefined() - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('does not clean up when registers still exist', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister1 = field._register() - const unregister2 = field._register() - - expect(field._atoms.store).toBeDefined() - - unregister1() - // Still mounted because of second registration - expect(field._isMounted).toBe(true) - - unregister2() - expect(field._isMounted).toBe(false) - - await vi.waitFor(() => { - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('cleans up atom caches for child fields when parent unregisters', async () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - - const unregisterParent = parent._register() - const unregisterChild = child._register() - - expect(parent._atoms.store).toBeDefined() - expect(child._atoms.store).toBeDefined() - - unregisterChild() - unregisterParent() - - await vi.waitFor(() => { - expect(parent._atoms.store).toBeUndefined() - // Child's atom cache should also be cleaned up (separate refCount) - expect(child._atoms.store).toBeUndefined() - }) - }) - }) -}) diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index 60c1c5943b..c3b9f81050 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -269,6 +269,39 @@ describe('InternalFormApi - _getOrCreateFieldApi', () => { }) describe('InternalFormApi - reset', () => { + it('notifies parent and child reset listeners with their values before removing the fields', () => { + const parentListener = vi.fn() + const childListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { user: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ + name: 'user', + listeners: [{ triggers: ['reset'], run: parentListener }], + }) + const child = form._getOrCreateFieldApi({ + name: 'user.name', + listeners: [{ triggers: ['reset'], run: childListener }], + }) + parent._register() + child._register() + child.handleChange('Bob') + + form.reset() + + expect(parentListener).toHaveBeenCalledOnce() + expect(parentListener).toHaveBeenCalledWith( + expect.objectContaining({ value: { name: 'Bob' } }), + ) + expect(childListener).toHaveBeenCalledOnce() + expect(childListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Bob' }), + ) + expect(form.state.values).toEqual({ user: { name: 'Alice' } }) + expect(form.getFieldMeta('user')).toBeUndefined() + expect(form.getFieldMeta('user.name')).toBeUndefined() + }) + it('aborts active validation on reset and allows validation to run again', async ({ onTestFinished, }) => { @@ -494,6 +527,68 @@ describe('InternalFormApi - resetField', () => { }) describe('InternalFormApi - deleteField', () => { + it('clears routed errors from a deleted subtree while preserving errors on other fields', async () => { + const form = new InternalFormApi({ + defaultValues: { user: { name: '' }, email: '' }, + validators: [ + { + triggers: [], + run: ({ createErrorMap }) => + createErrorMap({ + fields: { + 'user.name': 'Name is required', + email: 'Email is required', + }, + }), + }, + ], + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const name = form._getOrCreateFieldApi({ name: 'user.name' }) + const email = form._getOrCreateFieldApi({ name: 'email' }) + parent._register() + name._register() + email._register() + + await form.handleSubmit() + expect(name.errors).toEqual([{ message: 'Name is required' }]) + expect(email.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.canSubmit).toBe(false) + + form.deleteField('user') + + expect(form.getFieldMeta('user.name')).toBeUndefined() + expect(email.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + expect(form.state.canSubmit).toBe(false) + + form.deleteField('email') + + expect(form.state.isValid).toBe(true) + expect(form.state.canSubmit).toBe(true) + }) + + it('clears errors returned by onSubmit when their field is deleted', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createErrorMap }) => + createErrorMap({ fields: { name: 'Name is required' } }), + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.isValid).toBe(false) + expect(form.state.canSubmit).toBe(false) + + form.deleteField('name') + + expect(form.getFieldMeta('name')).toBeUndefined() + expect(form.state.isValid).toBe(true) + expect(form.state.canSubmit).toBe(true) + }) + it('removes field metadata and prevents further updates through the deleted field', () => { const listener = vi.fn() const form = new InternalFormApi({ defaultValues: { name: '' } }) diff --git a/packages/form-core/tests/devtoolsBridge.lib.spec.ts b/packages/form-core/tests/devtoolsBridge.lib.spec.ts index a8bec0aad4..8ef8f80381 100644 --- a/packages/form-core/tests/devtoolsBridge.lib.spec.ts +++ b/packages/form-core/tests/devtoolsBridge.lib.spec.ts @@ -3,6 +3,199 @@ import { InternalFormApi } from '../src/FormApi/FormApi.lib' import { installDevtoolsBridge } from '../src/devtoolsBridge.lib' describe('installDevtoolsBridge', () => { + it('reports a field mount once and unmounts it after its last consumer unregisters', async ({ + onTestFinished, + }) => { + vi.useFakeTimers() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const mountField = vi.fn() + const unmountField = vi.fn() + const uninstall = installDevtoolsBridge({ mountField, unmountField }) + onTestFinished(uninstall) + + const unregisterFirst = field._register() + const unregisterSecond = field._register() + + expect(mountField).toHaveBeenCalledOnce() + expect(mountField).toHaveBeenCalledWith(field) + + unregisterFirst() + expect(unmountField).not.toHaveBeenCalled() + + unregisterSecond() + await vi.runOnlyPendingTimersAsync() + + expect(unmountField).toHaveBeenCalledOnce() + expect(unmountField).toHaveBeenCalledWith(field, 'name') + }) + + it('reports moved fields with their previous paths after an array swap', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b'] }, + }) + const first = form._getOrCreateFieldApi({ name: 'items[0]' }) + const second = form._getOrCreateFieldApi({ name: 'items[1]' }) + const moveField = vi.fn() + const uninstall = installDevtoolsBridge({ moveField }) + onTestFinished(uninstall) + + form.swapFieldValues('items', 0, 1) + + expect(moveField).toHaveBeenCalledWith(first, 'items[0]') + expect(moveField).toHaveBeenCalledWith(second, 'items[1]') + expect(first.name).toBe('items[1]') + expect(second.name).toBe('items[0]') + expect(form.state.values.items).toEqual(['b', 'a']) + }) + + it('reports updates for a changed field and its parent even when both are already dirty', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { user: { name: '' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const field = form._getOrCreateFieldApi({ name: 'user.name' }) + const updateField = vi.fn() + const uninstall = installDevtoolsBridge({ updateField }) + onTestFinished(uninstall) + + field.handleChange('first') + + expect(field.meta.isDirty).toBe(true) + expect(parent.meta.isDirty).toBe(true) + expect(updateField).toHaveBeenCalledWith(field) + expect(updateField).toHaveBeenCalledWith(parent) + updateField.mockClear() + + field.handleChange('second') + + expect(field.meta.isDirty).toBe(true) + expect(parent.meta.isDirty).toBe(true) + expect(field.value).toBe('second') + expect(parent.value).toEqual({ name: 'second' }) + expect(updateField).toHaveBeenCalledWith(field) + expect(updateField).toHaveBeenCalledWith(parent) + }) + + it('reports field resets and updated defaults that bypass change events', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const updateField = vi.fn() + const uninstall = installDevtoolsBridge({ updateField }) + onTestFinished(uninstall) + + field.handleChange('changed') + updateField.mockClear() + + form.resetField('name') + + expect(field.value).toBe('') + expect(updateField).toHaveBeenCalledWith(field) + updateField.mockClear() + + form._update({ defaultValues: { name: 'new default' } }) + + expect(field.value).toBe('new default') + expect(updateField).toHaveBeenCalledWith(field) + }) + + it('reports every removed field in a subtree with its previous path', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { user: { name: '' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const child = form._getOrCreateFieldApi({ name: 'user.name' }) + const removeFieldSubtree = vi.fn() + const uninstall = installDevtoolsBridge({ removeFieldSubtree }) + onTestFinished(uninstall) + + form.deleteField('user') + + expect(removeFieldSubtree).toHaveBeenCalledOnce() + expect(removeFieldSubtree).toHaveBeenCalledWith(form, [ + { field: parent, previousPath: 'user' }, + { field: child, previousPath: 'user.name' }, + ]) + }) + + it('reports fields added beneath an existing parent', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { user: { name: 'Alice' } }, + }) + form._getOrCreateFieldApi({ name: 'user' }) + const fieldAdded = vi.fn() + const uninstall = installDevtoolsBridge({ fieldAdded }) + onTestFinished(uninstall) + + const child = form._getOrCreateFieldApi({ name: 'user.name' }) + + expect(fieldAdded).toHaveBeenCalledWith(child) + expect(child.name).toBe('user.name') + expect(child.value).toBe('Alice') + }) + + it('reports listener dependency endpoints when attached and when the watching field is deleted', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ name: 'target' }) + source._register() + target._register() + const listener = vi.fn() + const fieldDependenciesChanged = vi.fn() + const uninstall = installDevtoolsBridge({ fieldDependenciesChanged }) + onTestFinished(uninstall) + + target._update({ + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: listener }, + ], + }) + + expect(fieldDependenciesChanged).toHaveBeenCalledWith([ + expect.objectContaining({ + kind: 'listener', + sourceField: source, + watchingField: target, + listenerInstance: expect.anything(), + }), + ]) + + source.handleChange('first') + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target', fieldApi: target }), + ) + listener.mockClear() + fieldDependenciesChanged.mockClear() + + form.deleteField('target') + + expect(fieldDependenciesChanged).toHaveBeenCalledWith([ + expect.objectContaining({ + kind: 'listener', + sourceField: source, + watchingField: target, + listenerInstance: expect.anything(), + }), + ]) + + source.handleChange('after deletion') + expect(listener).not.toHaveBeenCalled() + }) + it('receives form mount, update, and unmount notifications', ({ onTestFinished, }) => { From 9de050f32095e17f7450eea1f60f3c14c9130df7 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:08:10 +0200 Subject: [PATCH 14/43] chore: add createErrorMap to submit context --- packages/form-core/src/FormApi/FormApi.public.ts | 7 +++++++ packages/form-core/src/FormApi/handleSubmit.lib.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index f7824c0907..8091f516bb 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -9,6 +9,7 @@ import type { ParsedStandardSchemaIssues, ToFormErrorTypes, ToFormOutputs, + ValidationErrorMap, } from '../validation.public' import type { StandardSchemaV1Issue } from '../standardSchema.public' import type { FormListeners } from '../listeners.public' @@ -62,6 +63,11 @@ export type CreateValidationErrorFn = < error: TError, ) => OnSubmitError +export type CreateSubmitErrorMapFn = ( + initial?: Partial>, +) => ValidationErrorMap & + OnSubmitError> + /** * Converts Standard Schema issues into an `onSubmit` validation error. * @@ -146,6 +152,7 @@ export interface FormSubmitContext< * ``` */ createValidationError: CreateValidationErrorFn + createErrorMap: CreateSubmitErrorMapFn /** * Parses Standard Schema issues into an error returnable from `onSubmit`. * diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index 634755d402..43beb1e991 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -5,13 +5,16 @@ import { parseValidationResult, } from '../validation' import { parseStandardSchemaIssues } from '../standardSchema.lib' +import { createErrorMap } from '../validation.public' import type { InternalFormApi } from './FormApi.lib' import type { FormValidateResult, FormValidationError, ValidationErrorInput, + ValidationErrorMap, } from '../validation.public' import type { + CreateSubmitErrorMapFn, CreateValidationErrorFn, OnSubmitError, ParseSubmitIssuesFn, @@ -39,6 +42,14 @@ function createSubmitError>( return { [SUBMIT_ERROR]: error } } +function createSubmitErrorMap( + initial?: Partial>, +): ReturnType> { + const map = createErrorMap(initial) + Object.defineProperty(map, SUBMIT_ERROR, { value: map }) + return map as ReturnType> +} + function createParseIssues( value: TFormData, ): ParseSubmitIssuesFn { @@ -168,6 +179,7 @@ export async function runSubmissionProcess( validatorOutputs, value, createValidationError, + createErrorMap: createSubmitErrorMap, parseIssues: createParseIssues(value), }) From db439775729a1b7fc6b3b317fd3ee93bac411618 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:08:25 +0200 Subject: [PATCH 15/43] chore: migrate unit tests for lifecycle --- .../tests/FieldApi/FieldApi.lib.spec.ts | 770 ++++++++++++++ .../tests/FieldApi/Lifecycle.spec.ts | 875 ---------------- .../tests/FormApi/FormApi.lib.spec.ts | 860 ++++++++++++++++ .../tests/FormApi/FormApi.public.test-d.ts | 68 ++ .../tests/FormApi/handleSubmit.lib.spec.ts | 72 ++ .../form-core/tests/FormApi/lifecycle.spec.ts | 953 ------------------ .../tests/devtoolsBridge.lib.spec.ts | 269 +++++ tsconfig.json | 2 +- 8 files changed, 2040 insertions(+), 1829 deletions(-) create mode 100644 packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts delete mode 100644 packages/form-core/tests/FieldApi/Lifecycle.spec.ts create mode 100644 packages/form-core/tests/FormApi/FormApi.lib.spec.ts create mode 100644 packages/form-core/tests/FormApi/FormApi.public.test-d.ts create mode 100644 packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts delete mode 100644 packages/form-core/tests/FormApi/lifecycle.spec.ts create mode 100644 packages/form-core/tests/devtoolsBridge.lib.spec.ts diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts new file mode 100644 index 0000000000..0cf633718e --- /dev/null +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -0,0 +1,770 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' + +describe('getOrCreateFieldApi', () => { + it('applies validators to a field whose value was set before registration', async () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + + form.setFieldValue('name', 'before render') + expect(form.getFieldMeta('name')).toMatchObject({ + isTouched: true, + isDirty: true, + }) + expect(form.state.isValid).toBe(true) + + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is invalid', triggers: [] }], + }) + field._register() + + expect(form.state.isValid).toBe(true) + expect(field.value).toBe('before render') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + }) + + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Name is invalid' }]) + expect(form.state.isValid).toBe(false) + }) +}) + +describe('InternalFieldApi - _update', () => { + it('retains default listeners before incoming listeners during construction and updates', () => { + const defaultListener = vi.fn() + const incomingListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi( + { defaultValues: { name: '' } }, + { + field: { + listenersMerge: 'append', + listeners: [{ triggers: ['change'], run: defaultListener }], + }, + }, + ) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['change'], run: incomingListener }], + }) + + field.handleChange('initial') + + expect(defaultListener).toHaveBeenCalledOnce() + expect(incomingListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(incomingListener) + expect(incomingListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + + defaultListener.mockClear() + incomingListener.mockClear() + field._update({ + listeners: [{ triggers: ['change'], run: updatedListener }], + }) + field.handleChange('updated') + + expect(defaultListener).toHaveBeenCalledOnce() + expect(updatedListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(updatedListener) + expect(updatedListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'updated' }), + ) + expect(incomingListener).not.toHaveBeenCalled() + }) + + it('applies the default error boundary only after configuration and respects an explicit override', async () => { + // This kinda masks a problem where an immediate mount validation on the form can 100% NOT respect + // a field component with `errorBoundary`. Too bad, I assume this won't happen much. + const form = new InternalFormApi( + { + defaultValues: { user: { name: '' } }, + validators: [ + { + triggers: ['change'], + run: ({ createErrorMap }) => + createErrorMap({ fields: { 'user.name': 'Name is required' } }), + }, + ], + }, + { field: { errorBoundary: true } }, + ) + const parent = form._getOrCreateFieldApi({ name: 'user' }, 'internal') + const child = form._getOrCreateFieldApi({ name: 'user.name' }, 'internal') + parent._register() + child._register() + + await form.validate('change') + expect(child.errors).toEqual([{ message: 'Name is required' }]) + expect(parent.errors).toEqual([]) + + form._getOrCreateFieldApi({ name: 'user' }) + + await form.validate('change') + expect(parent.errors).toEqual([{ message: 'Name is required' }]) + expect(child.errors).toEqual([]) + + parent._update({ errorBoundary: false }) + + await form.validate('change') + expect(child.errors).toEqual([{ message: 'Name is required' }]) + expect(parent.errors).toEqual([]) + }) + + it('uses updated validators, retains omitted validators, and removes validators with an empty array', async ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Initial error', triggers: [] }], + }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Initial error' }]) + + field._update({ + validators: [{ run: () => 'Updated error', triggers: [] }], + }) + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Updated error' }]) + + field._update({}) + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Updated error' }]) + + field._update({ validators: [] }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the validator array should not change', + ), + ) + await expect(form.handleSubmit()).resolves.toEqual([]) + expect(field.errors).toEqual([]) + expect(form.state.canSubmit).toBe(true) + }) + + it('uses updated listener callbacks and triggers, retains omitted listeners, and removes listeners with an empty array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const initialListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['change'], run: initialListener }], + }) + + field.handleChange('initial') + expect(initialListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + initialListener.mockClear() + + field._update({ + listeners: [{ triggers: ['blur'], run: updatedListener }], + }) + field.handleChange('updated') + + expect(initialListener).not.toHaveBeenCalled() + expect(updatedListener).not.toHaveBeenCalled() + + field.handleBlur() + expect(updatedListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'updated' }), + ) + updatedListener.mockClear() + + field._update({}) + field.handleBlur() + expect(updatedListener).toHaveBeenCalledOnce() + updatedListener.mockClear() + + field._update({ listeners: [] }) + field.handleBlur() + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('length of the listener array should not change'), + ) + expect(updatedListener).not.toHaveBeenCalled() + }) +}) + +describe('InternalFieldApi - atom', () => { + it('exposes the default value with untouched, valid state', () => { + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + const state = field.atom.get() + + expect(state).toMatchObject({ + value: 'Alice', + meta: { + errors: [], + isTouched: false, + isDirty: false, + isValid: true, + isValidating: false, + }, + }) + }) +}) + +describe('InternalFieldApi - reset', () => { + it('aborts active validation, ignores its late result, and allows validation to run again', async ({ + onTestFinished, + }) => { + const request = Promise.withResolvers() + onTestFinished(() => request.resolve('Late error')) + const onAbort = vi.fn() + const validator = vi.fn(() => request.promise) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: [], + run: ({ signal }: { signal: AbortSignal }) => { + signal.addEventListener('abort', onAbort, { once: true }) + return validator() + }, + }, + ], + }) + field._register() + + const submission = form.handleSubmit() + await expect.poll(() => validator).toHaveBeenCalledOnce() + expect(field.meta.isValidating).toBe(true) + + field.reset() + + expect(onAbort).toHaveBeenCalledOnce() + await expect(submission).resolves.toEqual([]) + request.resolve('Late error') + await request.promise + + expect(field.errors).toEqual([]) + expect(field.meta.isValidating).toBe(false) + + validator.mockResolvedValue('Current error') + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Current error' }]) + }) + + it('cancels a pending listener callback and allows subsequent changes to notify it', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { run: listener, triggers: ['change'], triggerDebounceMs: 100 }, + ], + }) + + field.handleChange('initial') + await vi.advanceTimersByTimeAsync(100) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + listener.mockClear() + + field.handleChange('pending') + field.reset() + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + expect(field.value).toBe('') + + field.handleChange('after reset') + await vi.advanceTimersByTimeAsync(100) + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'after reset' }), + ) + }) +}) + +describe('InternalFieldApi - _kill', () => { + it('does not run an unmounted field validator when a watched field is deleted and recreated', async () => { + vi.useFakeTimers() + const validator = vi.fn(() => null) + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { triggers: ['change'], watchFields: ['source'], run: validator }, + ], + }) + source._register() + const unregisterTarget = target._register() + + source.handleChange('first') + await vi.runOnlyPendingTimersAsync() + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target', event: 'change' }), + ) + validator.mockClear() + + unregisterTarget() + await vi.runOnlyPendingTimersAsync() + form.deleteField('source') + + form.setFieldValue('source', 'replacement') + await vi.runOnlyPendingTimersAsync() + + expect(validator).not.toHaveBeenCalled() + expect(form.getFieldValue('target')).toBe('target') + expect(form.state.isValid).toBe(true) + }) + + it('keeps a registered watcher usable after deleting its source and detaches the old dependency', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: listener }, + ], + }) + source._register() + target._register() + + source.handleChange('first') + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target' }), + ) + listener.mockClear() + + form.deleteField('source') + form.setFieldValue('source', 'replacement') + + expect(listener).not.toHaveBeenCalled() + + target.handleChange('direct edit') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'direct edit' }), + ) + expect(form.getFieldValue('target')).toBe('direct edit') + }) + + it('ignores array edits made through a deleted field', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + field._register() + field.pushValue('active') + + const beforeDelete = form.getFieldValue('items') + expect(beforeDelete).toEqual(['a', 'b', 'active']) + + form.deleteField('items') + + field.swapValues(0, 1) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.moveValue(0, 1) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.clearValues() + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.pushValue('ignored') + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.insertValue(1, 'ignored') + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.removeValue(0) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + field.filterValues(() => false) + expect(form.getFieldValue('items')).toEqual(beforeDelete) + }) + + it('ignores blur events made through a deleted field', () => { + const onBlur = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['blur'], run: onBlur }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + field.handleBlur() + expect(onBlur).toHaveBeenCalledWith( + expect.objectContaining({ triggerFieldApi: field }), + ) + onBlur.mockClear() + + form.deleteField('name') + field.handleBlur() + + expect(onBlur).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + }) + + it('aborts active validation when deleted and ignores late results and subsequent interactions', async ({ + onTestFinished, + }) => { + const request = Promise.withResolvers() + onTestFinished(() => request.resolve('Late error')) + const onAbort = vi.fn() + const validator = vi.fn(() => request.promise) + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ signal }: { signal: AbortSignal }) => { + signal.addEventListener('abort', onAbort, { once: true }) + return validator() + }, + }, + ], + }) + field._register() + + const submission = form.handleSubmit() + await expect.poll(() => validator).toHaveBeenCalledOnce() + expect(field.meta.isValidating).toBe(true) + + form.deleteField('name') + + expect(onAbort).toHaveBeenCalledOnce() + await expect(submission).resolves.toEqual([]) + request.resolve('Late error') + await request.promise + validator.mockClear() + + field.handleChange('ignored') + await expect(form.handleSubmit()).resolves.toEqual([]) + + expect(validator).not.toHaveBeenCalled() + expect(form.getFieldValue('name')).toBe('') + expect(form.state.isValid).toBe(true) + expect(form.state.isValidating).toBe(false) + expect(field.errors).toEqual([]) + }) + + it('cancels pending listener callbacks when deleted and ignores subsequent changes', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { run: listener, triggers: ['change'], triggerDebounceMs: 100 }, + ], + }) + + field.handleChange('initial') + await vi.advanceTimersByTimeAsync(100) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'initial' }), + ) + listener.mockClear() + + field.handleChange('pending') + form.deleteField('name') + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + + field.handleChange('ignored') + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldValue('name')).toBe('pending') + }) +}) + +describe('InternalFieldApi - _register', () => { + it('ignores registration and cleanup callbacks for a deleted field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ triggers: ['mount', 'unmount'], run: listener }], + }) + const unregister = field._register() + expect(listener).toHaveBeenCalledOnce() + listener.mockClear() + + form.deleteField('name') + const unregisterAfterDeletion = field._register() + unregisterAfterDeletion() + unregister() + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + }) +}) + +describe('InternalFieldApi - _unregister', () => { + it('continues validating a field while another consumer remains registered', async () => { + vi.useFakeTimers() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ triggers: [], run: () => 'Name is required' }], + }) + const unregisterFirst = field._register() + const unregisterSecond = field._register() + + unregisterFirst() + await vi.runOnlyPendingTimersAsync() + + await form.handleSubmit() + + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.canSubmit).toBe(false) + + unregisterSecond() + await vi.runOnlyPendingTimersAsync() + }) + + it('preserves nested default values and field interactions after unmounting and remounting', async () => { + vi.useFakeTimers() + const form = new InternalFormApi({ + defaultValues: { user: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const child = form._getOrCreateFieldApi({ name: 'user.name' }) + const unregisterParent = parent._register() + const unregisterChild = child._register() + expect(parent.value).toEqual({ name: 'Alice' }) + expect(child.value).toBe('Alice') + + unregisterChild() + unregisterParent() + await vi.runOnlyPendingTimersAsync() + + const remountedParent = form._getOrCreateFieldApi({ name: 'user' }) + const remountedChild = form._getOrCreateFieldApi({ name: 'user.name' }) + remountedParent._register() + remountedChild._register() + + expect(remountedChild.value).toBe('Alice') + expect(remountedChild.meta).toMatchObject({ + isTouched: false, + isDirty: false, + errors: [], + }) + + remountedChild.handleChange('Bob') + + expect(form.state.values).toEqual({ user: { name: 'Bob' } }) + expect(remountedParent.value).toEqual({ name: 'Bob' }) + expect(remountedParent.meta.isTouched).toBe(true) + }) + + it('preserves edited values and interaction state after unmounting and remounting', async () => { + vi.useFakeTimers() + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const unregister = field._register() + field.handleChange('Bob') + field.handleBlur() + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: true, + }) + + unregister() + await vi.runOnlyPendingTimersAsync() + + const remounted = form._getOrCreateFieldApi({ name: 'name' }) + remounted._register() + + expect(remounted.value).toBe('Bob') + expect(remounted.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: true, + }) + expect(form.state.isTouched).toBe(true) + }) + + it('keeps linked validation working after both field components unregister', async ({ + annotate, + }) => { + await annotate( + 'https://github.com/TanStack/form/issues/2296', + 'GitHub RFC link. Its decision affects the outcome of this test.', + ) + vi.useFakeTimers() + const validator = vi.fn(() => 'Target is invalid') + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ + name: 'target', + validators: [ + { triggers: ['change'], watchFields: ['source'], run: validator }, + ], + }) + const unregisterSource = source._register() + const unregisterTarget = target._register() + + unregisterSource() + unregisterTarget() + await vi.runOnlyPendingTimersAsync() + + form.setFieldValue('source', 'changed') + await vi.runOnlyPendingTimersAsync() + + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target', event: 'change' }), + ) + expect(form.getFieldMeta('target')?.errors).toEqual([ + { message: 'Target is invalid' }, + ]) + expect(form.state.isValid).toBe(false) + }) +}) + +describe('InternalFieldApi - _clearEventErrors', () => { + it('clears submit-only validator errors only for the field that changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const nameField = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is required', triggers: [] }], + }) + const emailField = form._getOrCreateFieldApi({ + name: 'email', + validators: [{ run: () => 'Email is required', triggers: [] }], + }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + it('clears submit errors when the field validator only listens for blur', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const nameField = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is required', triggers: ['blur'] }], + }) + const emailField = form._getOrCreateFieldApi({ + name: 'email', + validators: [{ run: () => 'Email is required', triggers: ['blur'] }], + }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + it('preserves errors produced by blur validation when the field changes', async () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Name is required', triggers: ['blur'] }], + }) + field._register() + + field.handleBlur() + await expect.poll(() => form.state.isValidating).toBe(false) + expect(field.errors).toEqual([{ message: 'Name is required' }]) + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(field.errors).toEqual([{ message: 'Name is required' }]) + }) + + it('replaces submit errors with the result of field change validation', async () => { + const validator = vi + .fn() + .mockReturnValueOnce('Submit field validator failed') + .mockReturnValue('Change field validator failed') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: validator, triggers: ['change'] }], + }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Submit field validator failed' }]) + expect(validator).toHaveBeenLastCalledWith( + expect.objectContaining({ event: 'submit' }), + ) + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(field.errors).toEqual([{ message: 'Change field validator failed' }]) + expect(validator).toHaveBeenLastCalledWith( + expect.objectContaining({ event: 'change' }), + ) + }) + + it('clears submit errors when field change validation is disabled by its predicate', async () => { + const validator = vi.fn(() => 'Submit field validator failed') + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + run: validator, + triggers: [{ trigger: 'change', when: false }], + }, + ], + }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Submit field validator failed' }]) + validator.mockClear() + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(field.errors).toEqual([]) + expect(validator).not.toHaveBeenCalled() + }) +}) diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts deleted file mode 100644 index b2e02dcdba..0000000000 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ /dev/null @@ -1,875 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - defaultFieldMeta, - defaultInternalBaseFieldMeta, -} from '../../src/FieldApi/fieldState.lib' -import { canPruneField } from '../../src/FieldApi/fieldTree.lib' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' -import { validationSourceScopes } from '../../src/ValidationSourceInstance.lib' -import { installDevtoolsBridge } from '../../src/devtoolsBridge.lib' - -describe('field - lifecycle', () => { - describe('default options', () => { - it('resolves defaults during construction and updates', () => { - const calls: Array = [] - const form = new InternalFormApi( - { defaultValues: { name: '', internal: '' } }, - { - field: { - errorBoundary: true, - listenersMerge: 'append', - listeners: [ - { triggers: ['change'], run: () => calls.push('default') }, - ], - }, - }, - ) - const internalField = form._getOrCreateFieldApi( - { name: 'internal' }, - 'internal', - ) - const field = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { triggers: ['change'], run: () => calls.push('incoming') }, - ], - }) - - expect(internalField._errorBoundary).toBe(false) - field.handleChange('initial') - expect(field._errorBoundary).toBe(true) - expect(calls).toEqual(['default', 'incoming']) - - calls.length = 0 - field._update({ - errorBoundary: false, - listeners: [{ triggers: ['change'], run: () => calls.push('updated') }], - }) - field.handleChange('updated') - expect(field._errorBoundary).toBe(false) - expect(calls).toEqual(['default', 'updated']) - }) - - it('configures a newly created field once', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const validator = { run: () => null, triggers: [] } - - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [validator], - }) - - expect(field._fieldOptionsInitialized).toBe(true) - expect(field._validatorInstances?.[0]?.definition).toBe(validator) - expect(field._validatorInstances?.[0]?.revision).toBe(0) - }) - - it('updates an internal field once when it is first configured', () => { - const form = new InternalFormApi( - { defaultValues: { x: '' } }, - { field: { errorBoundary: true } }, - ) - const field = form._getOrCreateFieldApi({ name: 'x' }, 'internal') - const update = vi.spyOn(field, '_update') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const initialValidator = { run: () => null, triggers: [] } - - expect(field._errorBoundary).toBe(false) - - const configuredField = form._getOrCreateFieldApi({ - name: 'x', - validators: [initialValidator], - }) - - expect(configuredField).toBe(field) - expect(field._fieldOptionsInitialized).toBe(true) - expect(field._errorBoundary).toBe(true) - expect(update).toHaveBeenCalledOnce() - expect(field._validatorInstances?.[0]?.definition).toBe(initialValidator) - expect(field._validatorInstances?.[0]?.revision).toBe(0) - expect(warn).not.toHaveBeenCalled() - - update.mockClear() - const nextValidator = { run: () => null, triggers: [] } - form._getOrCreateFieldApi({ - name: 'x', - validators: [nextValidator], - }) - - expect(update).not.toHaveBeenCalled() - expect(field._validatorInstances?.[0]?.definition).toBe(initialValidator) - - field._update({ validators: [nextValidator] }) - - expect(field._validatorInstances?.[0]?.definition).toBe(nextValidator) - expect(field._validatorInstances?.[0]?.revision).toBe(1) - expect(warn).not.toHaveBeenCalled() - - update.mockRestore() - warn.mockRestore() - }) - }) - - describe('_isMounted and atom', () => { - it('is false before the atom is accessed', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - expect(field._isMounted).toBe(false) - }) - - it('is true after registering', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - field._register() - expect(field._isMounted).toBe(true) - }) - - it('atom returns consistent state', () => { - const form = new InternalFormApi({ defaultValues: { x: 'hello' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - const state = field.atom.get() - expect(state.value).toBe('hello') - expect(state.meta).toMatchObject(defaultFieldMeta) - }) - }) - - describe('validator instances', () => { - it('keeps instances stable by slot and distinguishes omitted validators from an empty array', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const firstDefinition = { run: () => null, triggers: [] } - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [firstDefinition], - }) - const instance = field._validatorInstances?.[0] - const initialRevision = instance?.revision - const nextDefinition = { run: () => null, triggers: [] } - - field._update({ validators: [nextDefinition] }) - - expect(field._validatorInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(field) - expect(instance?.scope).toBe(validationSourceScopes.field) - expect(instance?.revision).toBe((initialRevision ?? 0) + 1) - - field._update({}) - expect(field._validatorInstances?.[0]).toBe(instance) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - field._update({ validators: [] }) - expect(field._validatorInstances).toBeNull() - expect(instance?.disposed).toBe(true) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('resets runtime on field reset and disposes instances on kill', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [{ run: () => null, triggers: [] }], - }) - const instance = field._validatorInstances?.[0] - const abortController = new AbortController() - instance?.setAbortController(abortController) - instance?.setOutput({ - output: 'output', - hasOutput: true, - }) - - field.reset() - - expect(field._validatorInstances?.[0]).toBe(instance) - expect(abortController.signal.aborted).toBe(true) - expect(instance?.hasOutput).toBe(false) - expect(instance?.disposed).toBe(false) - - field._kill() - - expect(field._validatorInstances).toBeNull() - expect(instance?.disposed).toBe(true) - }) - }) - - describe('listener instances', () => { - it('keeps instances stable by slot and distinguishes omitted listeners from an empty array', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const firstDefinition = { - run: () => {}, - triggers: ['change'] as Array<'change'>, - } - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [firstDefinition], - }) - const instance = field._listenerInstances?.[0] - const initialRevision = instance?.revision - const nextDefinition = { - run: () => {}, - triggers: ['blur'] as Array<'blur'>, - } - - field._update({ listeners: [nextDefinition] }) - - expect(field._listenerInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(field) - expect(instance?.index).toBe(0) - expect(instance?.revision).toBe((initialRevision ?? 0) + 1) - - field._update({}) - expect(field._listenerInstances?.[0]).toBe(instance) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - field._update({ listeners: [] }) - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'length of the listener array should not change', - ), - ) - expect(field._listenerInstances).toBeNull() - expect(instance?.disposed).toBe(true) - warn.mockRestore() - }) - - it('resets runtime on field reset and disposes instances on kill', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - listeners: [ - { - run: listener, - triggers: ['change'], - triggerDebounceMs: 100, - }, - ], - }) - const instance = field._listenerInstances?.[0] - - field.handleChange('pending') - field.reset() - await vi.advanceTimersByTimeAsync(100) - - expect(listener).not.toHaveBeenCalled() - expect(field._listenerInstances?.[0]).toBe(instance) - expect(instance?.debouncer).toBeNull() - expect(instance?.disposed).toBe(false) - - field._kill() - - expect(field._listenerInstances).toBeNull() - expect(instance?.disposed).toBe(true) - vi.useRealTimers() - }) - }) - - describe('devtools bridge notifications', () => { - it('notifies field mount and final unmount transitions only', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const mountField = vi.fn() - const unmountField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - mountField, - unmountField, - }) - - try { - const unregister1 = field._register() - const unregister2 = field._register() - - expect(mountField).toHaveBeenCalledOnce() - expect(mountField).toHaveBeenCalledWith(field) - - unregister1() - expect(unmountField).not.toHaveBeenCalled() - - unregister2() - expect(unmountField).toHaveBeenCalledOnce() - expect(unmountField).toHaveBeenCalledWith(field, 'name') - } finally { - uninstallBridge() - } - }) - - it('notifies field moves with the previous path', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const moveField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ moveField }) - - try { - form.swapFieldValues('items', 0, 1) - - expect(moveField).toHaveBeenCalledWith(field0, 'items[0]') - expect(moveField).toHaveBeenCalledWith(field1, 'items[1]') - expect(field0.name).toBe('items[1]') - expect(field1.name).toBe('items[0]') - } finally { - uninstallBridge() - } - }) - - it('notifies field meta updates for changed fields and their parents', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: '' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'user' }) - const child = form._getOrCreateFieldApi({ name: 'user.name' }) - const updateField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ updateField }) - - try { - child.handleChange('changed') - - expect(child.meta.isDirty).toBe(true) - expect(parent.meta.isDirty).toBe(true) - expect(updateField).toHaveBeenCalledWith(child) - expect(updateField).toHaveBeenCalledWith(parent) - } finally { - uninstallBridge() - } - }) - - it('notifies field value updates for the field and its parents even when summary meta is unchanged', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: '' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'user' }) - const field = form._getOrCreateFieldApi({ name: 'user.name' }) - const updateField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ updateField }) - - try { - field.handleChange('first') - updateField.mockClear() - - field.handleChange('second') - - expect(field.meta.isDirty).toBe(true) - expect(updateField).toHaveBeenCalledWith(field) - expect(updateField).toHaveBeenCalledWith(parent) - } finally { - uninstallBridge() - } - }) - - it('notifies field and default value updates that bypass field change events', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const updateField = vi.fn() - const uninstallBridge = installDevtoolsBridge({ updateField }) - - try { - field.handleChange('changed') - updateField.mockClear() - - form.resetField('name') - expect(updateField).toHaveBeenCalledWith(field) - - updateField.mockClear() - form._update({ defaultValues: { name: 'new default' } }) - expect(updateField).toHaveBeenCalledWith(field) - } finally { - uninstallBridge() - } - }) - - it('notifies removed field subtrees with previous paths', () => { - const form = new InternalFormApi({ - defaultValues: { user: { name: '' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'user' }) - const child = form._getOrCreateFieldApi({ name: 'user.name' }) - const removeFieldSubtree = vi.fn() - const uninstallBridge = installDevtoolsBridge({ removeFieldSubtree }) - - try { - parent._kill() - - expect(removeFieldSubtree).toHaveBeenCalledOnce() - expect(removeFieldSubtree).toHaveBeenCalledWith(form, [ - { field: parent, previousPath: 'user' }, - { field: child, previousPath: 'user.name' }, - ]) - } finally { - uninstallBridge() - } - }) - - it('notifies semantic field topology changes', () => { - const form = new InternalFormApi({ - defaultValues: { parent: {}, source: '', target: '' }, - }) - form._getOrCreateFieldApi({ name: 'parent' }) - const source = form._getOrCreateFieldApi({ name: 'source' }) - const target = form._getOrCreateFieldApi({ name: 'target' }) - const fieldAdded = vi.fn() - const fieldDependenciesChanged = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - fieldAdded, - fieldDependenciesChanged, - }) - - try { - const child = form._getOrCreateFieldApi({ name: 'parent.child' }) - - expect(fieldAdded).toHaveBeenCalledWith(child) - expect(form._tryGetFieldApi('parent.child')).toBe(child) - - target._update({ - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => {}, - }, - ], - }) - const listenerInstance = target._listenerInstances![0]! - - expect(fieldDependenciesChanged).toHaveBeenCalledWith([ - { - kind: 'listener', - sourceField: source, - watchingField: target, - listenerInstance, - }, - ]) - - fieldDependenciesChanged.mockClear() - target._kill() - - expect(fieldDependenciesChanged).toHaveBeenCalledWith([ - { - kind: 'listener', - sourceField: source, - watchingField: target, - listenerInstance, - }, - ]) - expect(source._watchingListenerFields).toBeNull() - } finally { - uninstallBridge() - } - }) - }) - - describe('_kill', () => { - it('unmounts the field atom', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - field._register() - expect(field._isMounted).toBe(true) - field._kill() - expect(field._isMounted).toBe(false) - expect(field._isKilled).toBe(true) - }) - - it('also kills child fields', () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - void parent.atom - void child.atom - parent._kill() - expect(parent._isMounted).toBe(false) - expect(child._isMounted).toBe(false) - expect(parent._isKilled).toBe(true) - expect(child._isKilled).toBe(true) - }) - - it('can notify a listener event before killing the field subtree', () => { - const parentListener = vi.fn() - const childListener = vi.fn() - - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ - name: 'a', - listeners: [{ triggers: ['reset'], run: parentListener }], - }) - form._getOrCreateFieldApi({ - name: 'a.b', - listeners: [{ triggers: ['reset'], run: childListener }], - }) - - parent._kill({ listenerEvent: 'reset' }) - - expect(parentListener).toHaveBeenCalledOnce() - expect(childListener).toHaveBeenCalledOnce() - }) - - it('ignores validation started after a field is killed', async () => { - const validator = vi.fn(() => ({ message: 'Too late' })) - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [{ run: validator, triggers: [] }], - }) - - field._kill() - - const result = await field._runFieldValidation('submit') - - expect(validator).not.toHaveBeenCalled() - expect(result.results).toEqual([]) - expect(field.errors).toEqual([]) - }) - - it('ignores validation results that arrive after a field is killed', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'x', - validators: [{ run: () => null, triggers: [] }], - }) - const validatorInstance = field._validatorInstances![0]! - - field._kill() - field._processValidationResult( - { - validatorInstance, - result: { message: 'Too late' }, - output: null, - }, - 'submit', - ) - - expect(field.errors).toEqual([]) - }) - - it('ignores stale field writes after a field is killed', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - field._kill() - field.handleChange('ignored') - - expect(form.getFieldValue('x')).toBe('') - }) - - it('ignores stale array and blur methods after a field is killed', () => { - const formListener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - listeners: [{ triggers: ['blur'], run: formListener }], - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - field._kill() - field.swapValues(0, 1) - field.moveValue(0, 1) - field.clearValues() - field.pushValue('c') - field.insertValue(1, 'x') - field.removeValue(0) - field.filterValues(() => false) - field.handleBlur() - - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - expect(formListener).not.toHaveBeenCalled() - }) - - it('returns a no-op unregister callback for a killed field', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - field._kill() - const unregister = field._register() - unregister() - field._unregister() - - expect(field._isMounted).toBe(false) - }) - - it('does not prune killed fields or fields with retained meta state', () => { - const cases = [ - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, isDirty: true })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, isBlurred: true })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, isValidating: true })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, _validationCount: 1 })), - ( - field: ReturnType< - InternalFormApi['_getOrCreateFieldApi'] - >, - ) => field._setMeta((prev) => ({ ...prev, _arrayVersion: 1 })), - ] - - cases.forEach((markRetainedMeta, index) => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: `x${index}` }) - markRetainedMeta(field) - - expect(canPruneField(field)).toBe(false) - }) - - const form = new InternalFormApi({ defaultValues: { killed: '' } }) - const killedField = form._getOrCreateFieldApi({ name: 'killed' }) - killedField._kill() - - expect(canPruneField(killedField)).toBe(false) - }) - - it('removes killed fields from form-level routed error bookkeeping', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - triggers: [], - run: () => ({ - fields: { - name: 'Name is required', - }, - }), - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.validate('submit') - expect(field.errors).toEqual([{ message: 'Name is required' }]) - expect(form.state.canSubmit).toBe(false) - - field._kill() - - expect(form.state.canSubmit).toBe(true) - expect(form._validatorInstances?.[0]?.errorTargets).toBeNull() - }) - - it('removes killed fields from onSubmit routed error bookkeeping', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - onSubmit: ({ createValidationError }) => - createValidationError({ fields: { name: 'Name is required' } }), - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form._onSubmitSource.errorTargets).toEqual(new Set([field])) - - field._kill() - - expect(form._onSubmitSource.errorTargets).toBeNull() - }) - - it('preserves routed errors for fields outside the killed subtree', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', other: '' }, - validators: [ - { - triggers: [], - run: () => ({ - fields: { - name: 'Name is required', - }, - }), - }, - ], - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const otherField = form._getOrCreateFieldApi({ name: 'other' }) - - await form.validate('submit') - otherField._kill() - - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(form.state.canSubmit).toBe(false) - }) - - it('does not prune either endpoint while watched validators are attached', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => null, - }, - ], - }) - - expect(canPruneField(sourceField)).toBe(false) - expect(canPruneField(targetField)).toBe(false) - }) - - it('prunes an unmounted watcher after its final source is killed', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - form._getOrCreateFieldApi({ - name: 'target', - validators: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => null, - }, - ], - }) - - sourceField._kill() - - expect(form._tryGetFieldApi('source')).toBeNull() - expect(form._tryGetFieldApi('target')).toBeNull() - }) - - it('keeps a mounted watcher after its source is killed', () => { - const form = new InternalFormApi({ - defaultValues: { source: '', target: '' }, - }) - const sourceField = form._getOrCreateFieldApi({ name: 'source' }) - const targetField = form._getOrCreateFieldApi({ - name: 'target', - listeners: [ - { - triggers: ['change'], - watchFields: ['source'], - run: () => {}, - }, - ], - }) - const unregisterTarget = targetField._register() - - try { - sourceField._kill() - - expect(form._tryGetFieldApi('target')).toBe(targetField) - expect( - targetField._listenerInstances?.[0]?.resolvedWatchFields, - ).toBeNull() - } finally { - unregisterTarget() - } - }) - }) - - describe('_unregister', () => { - it('unmounts when no registers remain', () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._isMounted).toBe(true) - - unregister() - expect(field._isMounted).toBe(false) - }) - - it('cleans up the atom cache on unmount', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._atoms.store).toBeDefined() - - unregister() - - await vi.waitFor(() => { - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('cleans up atoms on unmount when meta was not modified', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._atoms.meta).toBeDefined() - expect(field._atoms.meta?.get()).toBe(defaultInternalBaseFieldMeta) - - unregister() - - await vi.waitFor(() => { - expect(field._atoms.meta).toBeUndefined() - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('keeps meta atom on unmount when meta was modified', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister = field._register() - expect(field._atoms.meta).toBeDefined() - expect(field._atoms.meta?.get()).toBe(defaultInternalBaseFieldMeta) - - field.handleChange('New value') - - unregister() - - await vi.waitFor(() => { - expect(field._atoms.meta).toBeDefined() - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('does not clean up when registers still exist', async () => { - const form = new InternalFormApi({ defaultValues: { x: '' } }) - const field = form._getOrCreateFieldApi({ name: 'x' }) - - const unregister1 = field._register() - const unregister2 = field._register() - - expect(field._atoms.store).toBeDefined() - - unregister1() - // Still mounted because of second registration - expect(field._isMounted).toBe(true) - - unregister2() - expect(field._isMounted).toBe(false) - - await vi.waitFor(() => { - expect(field._atoms.store).toBeUndefined() - }) - }) - - it('cleans up atom caches for child fields when parent unregisters', async () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - - const unregisterParent = parent._register() - const unregisterChild = child._register() - - expect(parent._atoms.store).toBeDefined() - expect(child._atoms.store).toBeDefined() - - unregisterChild() - unregisterParent() - - await vi.waitFor(() => { - expect(parent._atoms.store).toBeUndefined() - // Child's atom cache should also be cleaned up (separate refCount) - expect(child._atoms.store).toBeUndefined() - }) - }) - }) -}) diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts new file mode 100644 index 0000000000..834da29cc9 --- /dev/null +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -0,0 +1,860 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' +import { formOptions } from '../../src' + +describe('InternalFormApi - constructor', () => { + it('initializes default values with untouched, submittable state', () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice', age: 30 }, + }) + + expect(form.state).toMatchObject({ + values: { name: 'Alice', age: 30 }, + isTouched: false, + canSubmit: true, + }) + }) + + it('uses a supplied formId', () => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + + expect(form.formId).toBe('profile-form') + }) +}) + +describe('InternalFormApi - _update', () => { + it('changes a supplied formId', () => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + + form._update({ + formId: 'renamed-profile-form', + defaultValues: { name: '' }, + }) + + expect(form.formId).toBe('renamed-profile-form') + }) + + it('generates a nonempty formId and preserves it when options update', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const formId = form.formId + + expect(formId).toBeTypeOf('string') + expect(formId.length).toBeGreaterThan(0) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.formId).toBe(formId) + }) + + it('applies updated defaults to an untouched field without marking it touched', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + expect(field.value).toBe('') + expect(field.meta.isTouched).toBe(false) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.state.values).toEqual({ name: 'async' }) + expect(form.defaultValues).toEqual({ name: 'async' }) + expect(field.value).toBe('async') + expect(field.meta.isTouched).toBe(false) + expect(form.state.isTouched).toBe(false) + }) + + it('preserves user edits while applying updated defaults to untouched fields', () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const nameField = form._getOrCreateFieldApi({ name: 'name' }) + form._getOrCreateFieldApi({ name: 'email' }) + + nameField.handleChange('user edit') + form._update({ + defaultValues: { name: 'server name', email: 'server@example.com' }, + }) + + expect(form.state.values).toEqual({ + name: 'user edit', + email: 'server@example.com', + }) + expect(form.defaultValues).toEqual({ + name: 'server name', + email: 'server@example.com', + }) + }) + + it('warns when the validator array length changes', ({ onTestFinished }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [{ run: () => null, triggers: [] }], + }) + + form._update({ + defaultValues: { name: '' }, + validators: [ + { run: () => null, triggers: [] }, + // @ts-expect-error We're testing this on purpose + { run: () => null, triggers: [] }, + ], + }) + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'length of the validator array should not change', + ), + ) + }) + + it('uses the updated validator callback on subsequent validation', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [{ run: () => 'Initial error', triggers: ['change'] }], + }) + + await form.validate('change') + expect(form.state.errors).toEqual([{ message: 'Initial error' }]) + + form._update({ + defaultValues: { name: '' }, + validators: [{ run: () => 'Updated error', triggers: ['change'] }], + }) + await form.validate('change') + + expect(form.state.errors).toEqual([{ message: 'Updated error' }]) + }) + + it('uses the updated listener callback and triggers', () => { + const initialListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: initialListener }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + field.handleChange('before update') + expect(initialListener).toHaveBeenCalledOnce() + initialListener.mockClear() + + form._update({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['blur'], run: updatedListener }], + }) + field.handleChange('after update') + expect(initialListener).not.toHaveBeenCalled() + expect(updatedListener).not.toHaveBeenCalled() + + field.handleBlur() + + expect(updatedListener).toHaveBeenCalledOnce() + expect(initialListener).not.toHaveBeenCalled() + }) + + it('uses the updated onSubmit callback and replaces its previous errors', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Initial submit error'), + }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Initial submit error' }]) + + form._update({ + defaultValues: { name: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Updated submit error'), + }) + await form.handleSubmit() + + expect(form.state.errors).toEqual([{ message: 'Updated submit error' }]) + }) + + it('retains default listeners when incoming listeners update', () => { + const defaultListener = vi.fn() + const incomingListener = vi.fn() + const updatedListener = vi.fn() + const form = new InternalFormApi( + { + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: incomingListener }], + }, + { + form: { + listenersMerge: 'append', + listeners: [{ triggers: ['change'], run: defaultListener }], + }, + }, + ) + + form.setFieldValue('name', 'initial') + expect(defaultListener).toHaveBeenCalledOnce() + expect(incomingListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(incomingListener) + + defaultListener.mockClear() + incomingListener.mockClear() + form._update({ + defaultValues: { name: '' }, + listeners: [{ triggers: ['change'], run: updatedListener }], + }) + form.setFieldValue('name', 'updated') + + expect(defaultListener).toHaveBeenCalledOnce() + expect(updatedListener).toHaveBeenCalledOnce() + expect(defaultListener).toHaveBeenCalledBefore(updatedListener) + expect(incomingListener).not.toHaveBeenCalled() + }) + + it('preserves reset defaults until the incoming defaults change', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.state.values).toEqual({ name: 'async' }) + expect(form.defaultValues).toEqual({ name: 'async' }) + expect(form.state.isDefaultValue).toBe(true) + + form.reset({ name: 'reset default' }) + + expect(form.state.values).toEqual({ name: 'reset default' }) + expect(form.defaultValues).toEqual({ name: 'reset default' }) + expect(form.state.isDefaultValue).toBe(true) + + form._update({ defaultValues: { name: 'async' } }) + + expect(form.state.values).toEqual({ name: 'reset default' }) + expect(form.defaultValues).toEqual({ name: 'reset default' }) + expect(form.state.isDefaultValue).toBe(true) + + form._update({ defaultValues: { name: 'new async' } }) + + expect(form.state.values).toEqual({ name: 'new async' }) + expect(form.defaultValues).toEqual({ name: 'new async' }) + expect(form.state.isDefaultValue).toBe(true) + }) +}) + +describe('InternalFormApi - _getOrCreateFieldApi', () => { + it('attaches nested field listeners only to the requested field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { foo: { bar: '' } }, + }) + const field = form._getOrCreateFieldApi({ + name: 'foo.bar', + listeners: [{ run: listener, triggers: ['change'] }], + }) + const parent = form._getOrCreateFieldApi({ name: 'foo' }) + + parent.handleChange({ bar: 'parent edit' }) + + expect(listener).not.toHaveBeenCalled() + + field.handleChange('field edit') + + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ fieldApi: field, value: 'field edit' }), + ) + }) +}) + +describe('InternalFormApi - reset', () => { + it('notifies parent and child reset listeners with their values before removing the fields', () => { + const parentListener = vi.fn() + const childListener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { user: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ + name: 'user', + listeners: [{ triggers: ['reset'], run: parentListener }], + }) + const child = form._getOrCreateFieldApi({ + name: 'user.name', + listeners: [{ triggers: ['reset'], run: childListener }], + }) + parent._register() + child._register() + child.handleChange('Bob') + + form.reset() + + expect(parentListener).toHaveBeenCalledOnce() + expect(parentListener).toHaveBeenCalledWith( + expect.objectContaining({ value: { name: 'Bob' } }), + ) + expect(childListener).toHaveBeenCalledOnce() + expect(childListener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Bob' }), + ) + expect(form.state.values).toEqual({ user: { name: 'Alice' } }) + expect(form.getFieldMeta('user')).toBeUndefined() + expect(form.getFieldMeta('user.name')).toBeUndefined() + }) + + it('aborts active validation on reset and allows validation to run again', async ({ + onTestFinished, + }) => { + const request = Promise.withResolvers() + onTestFinished(() => request.resolve('Late error')) + const onAbort = vi.fn() + const validator = vi.fn(() => request.promise) + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + run: ({ signal }) => { + signal.addEventListener('abort', onAbort, { once: true }) + return validator() + }, + }, + ], + }) + const form = new InternalFormApi(options) + + const validation = form.validate('change') + expect(validator).toHaveBeenCalledOnce() + expect(form.state.isValidating).toBe(true) + + form.reset() + + expect(onAbort).toHaveBeenCalledOnce() + await expect(validation).resolves.toEqual([]) + request.resolve('Late error') + await request.promise + + expect(form.state.errors).toEqual([]) + expect(form.state.isValidating).toBe(false) + + validator.mockResolvedValue('Current error') + await form.validate('change') + + expect(form.state.errors).toEqual([{ message: 'Current error' }]) + }) + + it('ignores the result of a submission that finishes after reset', async ({ + onTestFinished, + }) => { + const { promise: request, resolve: finishRequest } = + Promise.withResolvers() + onTestFinished(() => finishRequest()) + const options = formOptions({ + defaultValues: { name: '' }, + onSubmit: async ({ createValidationError }) => { + await request + return createValidationError('Late submit error') + }, + }) + const onSubmit = vi.spyOn(options, 'onSubmit') + const form = new InternalFormApi(options) + + const submission = form.handleSubmit() + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + }) + expect(form.state.isSubmitting).toBe(true) + + form.reset({ name: 'reset value' }, { updateDefaultValues: false }) + expect(form.state.isSubmitting).toBe(false) + + finishRequest() + + await expect(submission).resolves.toEqual([]) + expect(form.state).toMatchObject({ + values: { name: 'reset value' }, + errors: [], + isSubmitting: false, + isSubmitSuccessful: false, + submissionAttempts: 0, + }) + }) + + it('restores values and clears field, validation, and submission state', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [{ run: () => 'Validation error', triggers: ['change'] }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + field.handleChange('dirty') + await form.validate('change') + await form.handleSubmit() + + expect(form.state).toMatchObject({ + values: { name: 'dirty' }, + isDirty: true, + isTouched: true, + errors: [{ message: 'Validation error' }], + submissionAttempts: 1, + }) + + form.reset() + + expect(form.state).toMatchObject({ + values: { name: '' }, + isDirty: false, + isTouched: false, + errors: [], + submissionAttempts: 0, + canSubmit: true, + }) + expect(form.getFieldMeta('name')).toBeUndefined() + }) + + it('cancels pending form validation without applying late errors', async () => { + vi.useFakeTimers() + const validator = vi.fn(() => 'Late error') + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + triggers: ['change'], + triggerDebounceMs: 100, + run: validator, + }, + ], + }) + + const validation = form.validate('change') + expect(form.state.isValidating).toBe(true) + + form.reset() + await vi.advanceTimersByTimeAsync(100) + + await expect(validation).resolves.toEqual([]) + expect(validator).not.toHaveBeenCalled() + expect(form.state.errors).toEqual([]) + expect(form.state.isValidating).toBe(false) + }) + + it('cancels pending field listener callbacks', async () => { + vi.useFakeTimers() + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [ + { + triggers: ['change'], + triggerDebounceMs: 100, + run: listener, + }, + ], + }) + field._register() + + field.handleChange('first edit') + await vi.advanceTimersByTimeAsync(100) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'first edit' }), + ) + listener.mockClear() + + field.handleChange('pending edit') + form.reset() + await vi.advanceTimersByTimeAsync(100) + + expect(listener).not.toHaveBeenCalled() + expect(form.state.values).toEqual({ name: '' }) + }) +}) + +describe('InternalFormApi - resetField', () => { + it('restores the field default and clears touched state and validation errors', async () => { + const form = new InternalFormApi({ defaultValues: { name: 'hi' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [{ run: () => 'Reset me', triggers: [] }], + }) + field._register() + + field.handleChange('bye') + await form.handleSubmit() + + expect(field.value).toBe('bye') + expect(field.meta.isTouched).toBe(true) + expect(field.errors).toEqual([{ message: 'Reset me' }]) + expect(form.state.isValid).toBe(false) + + form.resetField('name') + + expect(field.value).toBe('hi') + expect(field.meta.isTouched).toBe(false) + expect(field.errors).toEqual([]) + expect(form.state.isTouched).toBe(false) + expect(form.state.isValid).toBe(true) + }) + + it('resets descendant values and touched state while keeping fields usable', () => { + const form = new InternalFormApi({ + defaultValues: { person: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'person' }) + const child = form._getOrCreateFieldApi({ name: 'person.name' }) + parent._register() + child._register() + + child.handleChange('Bob') + child.handleBlur() + + expect(parent.meta.isTouched).toBe(true) + expect(child.meta.isTouched).toBe(true) + + form.resetField('person') + + expect(parent.value).toEqual({ name: 'Alice' }) + expect(child.value).toBe('Alice') + expect(parent.meta.isTouched).toBe(false) + expect(child.meta.isTouched).toBe(false) + expect(form.state.isTouched).toBe(false) + + child.handleChange('Carol') + + expect(form.state.values).toEqual({ person: { name: 'Carol' } }) + expect(form.state.isTouched).toBe(true) + }) +}) + +describe('InternalFormApi - deleteField', () => { + it('clears routed errors from a deleted subtree while preserving errors on other fields', async () => { + const form = new InternalFormApi({ + defaultValues: { user: { name: '' }, email: '' }, + validators: [ + { + triggers: [], + run: ({ createErrorMap }) => + createErrorMap({ + fields: { + 'user.name': 'Name is required', + email: 'Email is required', + }, + }), + }, + ], + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const name = form._getOrCreateFieldApi({ name: 'user.name' }) + const email = form._getOrCreateFieldApi({ name: 'email' }) + parent._register() + name._register() + email._register() + + await form.handleSubmit() + expect(name.errors).toEqual([{ message: 'Name is required' }]) + expect(email.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.canSubmit).toBe(false) + + form.deleteField('user') + + expect(form.getFieldMeta('user.name')).toBeUndefined() + expect(email.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + expect(form.state.canSubmit).toBe(false) + + form.deleteField('email') + + expect(form.state.isValid).toBe(true) + expect(form.state.canSubmit).toBe(true) + }) + + it('clears errors returned by onSubmit when their field is deleted', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createErrorMap }) => + createErrorMap({ fields: { name: 'Name is required' } }), + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.isValid).toBe(false) + expect(form.state.canSubmit).toBe(false) + + form.deleteField('name') + + expect(form.getFieldMeta('name')).toBeUndefined() + expect(form.state.isValid).toBe(true) + expect(form.state.canSubmit).toBe(true) + }) + + it('removes field metadata and prevents further updates through the deleted field', () => { + const listener = vi.fn() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ + name: 'name', + listeners: [{ run: listener, triggers: ['change'] }], + }) + field._register() + field.handleChange('Alice') + + expect(form.getFieldMeta('name')).toMatchObject({ isTouched: true }) + expect(form.state.isTouched).toBe(true) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Alice' }), + ) + listener.mockClear() + + form.deleteField('name') + field.handleChange('Bob') + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldMeta('name')).toBeUndefined() + expect(form.state.values).toEqual({ name: 'Alice' }) + expect(form.state.isTouched).toBe(false) + }) + + it('leaves other values and touched state intact when the field does not exist', () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + }) + const emailField = form._getOrCreateFieldApi({ name: 'email' }) + emailField.handleChange('alice@example.com') + expect(form.getFieldMeta('name')).toBeUndefined() + + form.deleteField('name') + + expect(form.state.values).toEqual({ name: '', email: 'alice@example.com' }) + expect(emailField.meta.isTouched).toBe(true) + expect(form.state.isTouched).toBe(true) + }) + + it('removes descendant metadata and prevents updates through deleted fields', () => { + const listener = vi.fn() + const form = new InternalFormApi({ + defaultValues: { person: { name: 'Alice' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'person' }) + const child = form._getOrCreateFieldApi({ + name: 'person.name', + listeners: [{ run: listener, triggers: ['change'] }], + }) + parent._register() + child._register() + child.handleChange('Bob') + + expect(parent.meta.isTouched).toBe(true) + expect(child.meta.isTouched).toBe(true) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'Bob' }), + ) + listener.mockClear() + + form.deleteField('person') + child.handleChange('Carol') + parent.handleChange({ name: 'Dana' }) + + expect(listener).not.toHaveBeenCalled() + expect(form.getFieldMeta('person')).toBeUndefined() + expect(form.getFieldMeta('person.name')).toBeUndefined() + expect(form.state.values).toEqual({ person: { name: 'Bob' } }) + expect(form.state.isTouched).toBe(false) + }) +}) + +describe('InternalFormApi - _clearEventErrors', () => { + it('clears submit errors when change validation is disabled by its predicate', async () => { + const validator = vi.fn(() => 'Submit validator failed') + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + run: validator, + triggers: [{ trigger: 'change', when: false }], + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submit validator failed' }]) + validator.mockClear() + + field.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(form.state.errors).toEqual([]) + expect(validator).not.toHaveBeenCalled() + }) + + it('clears onSubmit field errors only for the field that changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createErrorMap }) => + createErrorMap({ + fields: { + name: 'Name is required', + email: 'Email is required', + }, + }), + }) + const nameField = form._getOrCreateFieldApi({ name: 'name' }) + const emailField = form._getOrCreateFieldApi({ name: 'email' }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleChange('Alice') + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + // TODO: Clearing onSubmit errors on blur is debated and may change. + it('clears onSubmit field errors only for the field that blurs', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createErrorMap }) => + createErrorMap({ + fields: { + name: 'Name is required', + email: 'Email is required', + }, + }), + }) + const nameField = form._getOrCreateFieldApi({ name: 'name' }) + const emailField = form._getOrCreateFieldApi({ name: 'email' }) + nameField._register() + emailField._register() + + await form.handleSubmit() + expect(nameField.errors).toEqual([{ message: 'Name is required' }]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + + nameField.handleBlur() + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nameField.errors).toEqual([]) + expect(emailField.errors).toEqual([{ message: 'Email is required' }]) + expect(form.state.isValid).toBe(false) + }) + + it('clears form-level onSubmit errors when a field changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Submission failed'), + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submission failed' }]) + expect(form.state.canSubmit).toBe(false) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([]) + expect(form.state.canSubmit).toBe(true) + }) + + // TODO clearing onSubmit errors on blur is debated and may change. + it('clears form-level onSubmit errors when a field blurs', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + onSubmit: ({ createValidationError }) => + createValidationError('Submission failed'), + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submission failed' }]) + + field.handleBlur() + + expect(form.state.errors).toEqual([]) + }) + + it('clears submit-only validator errors when a field changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [{ run: () => 'Submit-only validator failed', triggers: [] }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([ + { message: 'Submit-only validator failed' }, + ]) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([]) + }) + + it('clears submit validator errors when the validator only listens for blur', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [ + { run: () => 'Submit or blur validator failed', triggers: ['blur'] }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([ + { message: 'Submit or blur validator failed' }, + ]) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([]) + }) + + it('preserves errors produced by blur validation when a field changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '', email: '' }, + validators: [{ run: () => 'Blur validator failed', triggers: ['blur'] }], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.validate('blur') + expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) + + field.handleChange('Alice') + + expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) + }) + + it('replaces submit errors with the result of change validation', async () => { + const options = formOptions({ + defaultValues: { name: '', email: '' }, + validators: [ + { + run: ({ event }) => + event === 'submit' + ? 'Submit validator failed' + : 'Change validator failed', + triggers: ['change'], + }, + ], + }) + const form = new InternalFormApi(options) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + await form.handleSubmit() + expect(form.state.errors).toEqual([{ message: 'Submit validator failed' }]) + + field.handleChange('Alice') + + await expect + .poll(() => form.state.errors) + .toEqual([{ message: 'Change validator failed' }]) + }) +}) diff --git a/packages/form-core/tests/FormApi/FormApi.public.test-d.ts b/packages/form-core/tests/FormApi/FormApi.public.test-d.ts new file mode 100644 index 0000000000..096d273118 --- /dev/null +++ b/packages/form-core/tests/FormApi/FormApi.public.test-d.ts @@ -0,0 +1,68 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { formOptions } from '../../src' +import type { + CreateSubmitErrorMapFn, + OnSubmitError, + ParseSubmitIssuesFn, + StandardSchemaV1Issue, + ToFormErrorTypes, + ValidationErrorMap, + ValidationIssue, +} from '../../src' + +type FormData = { name: string; user: { age: number } } + +describe('FormSubmitContext', () => { + it('infers mutable submission error maps and restricts their field paths', () => { + const options = formOptions({ + defaultValues: { name: '', user: { age: 0 } }, + onSubmit: ({ createErrorMap, parseIssues }) => { + expectTypeOf(createErrorMap).toEqualTypeOf< + CreateSubmitErrorMapFn + >() + expectTypeOf(parseIssues).toEqualTypeOf>() + + const errors = createErrorMap({ form: 'Submission failed' }) + expectTypeOf(errors).toEqualTypeOf< + ValidationErrorMap & + OnSubmitError> + >() + + errors.fields.name = 'Name is required' + errors.fields['user.age'] = { message: 'Age is required' } + // @ts-expect-error Submission error maps require valid form field paths. + errors.fields.missing = 'Missing' + // @ts-expect-error Initial error maps require valid form field paths. + createErrorMap({ fields: { missing: 'Missing' } }) + // @ts-expect-error Standard Schema issues are handled by parseIssues. + createErrorMap([{ message: 'Name is required', path: ['name'] }]) + + return errors + }, + }) + + type SubmitReturn = ReturnType> + expectTypeOf< + ToFormErrorTypes<[], SubmitReturn>['formError'] + >().toEqualTypeOf() + expectTypeOf< + ToFormErrorTypes<[], SubmitReturn>['fieldError'] + >().toEqualTypeOf() + }) + + it('keeps parsed Standard Schema submission errors separately typed', () => { + const options = formOptions({ + defaultValues: { name: '', user: { age: 0 } }, + onSubmit: ({ parseIssues }) => + parseIssues([{ message: 'Name is required', path: ['name'] }]), + }) + + type SubmitReturn = ReturnType> + expectTypeOf< + ToFormErrorTypes<[], SubmitReturn>['formError'] + >().toEqualTypeOf() + expectTypeOf< + ToFormErrorTypes<[], SubmitReturn>['fieldError'] + >().toEqualTypeOf() + }) +}) diff --git a/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts new file mode 100644 index 0000000000..d534f7bd85 --- /dev/null +++ b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' + +describe('runSubmissionProcess', () => { + it('applies form and field errors added to a submission error map', async () => { + const onSubmitInvalid = vi.fn() + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createErrorMap }) => { + const errors = createErrorMap({ form: 'Initial submit error' }) + errors.form = 'Submission failed' + errors.fields.name = 'Name is required' + return errors + }, + onSubmitInvalid, + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + const result = await form.handleSubmit() + + expect(result).toEqual([ + { + form: [{ message: 'Submission failed' }], + fields: { name: [{ message: 'Name is required' }] }, + }, + ]) + expect(form.state.errors).toEqual([{ message: 'Submission failed' }]) + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.isSubmitSuccessful).toBe(false) + expect(onSubmitInvalid).toHaveBeenCalledWith({ + value: { name: '' }, + formApi: form, + }) + }) + + it('clears previous submission errors and succeeds when the returned map is empty', async () => { + const onSubmitInvalid = vi.fn() + let shouldReject = true + const form = new InternalFormApi({ + defaultValues: { name: '' }, + onSubmit: ({ createErrorMap }) => { + const errors = createErrorMap() + if (shouldReject) errors.fields.name = 'Name is required' + return errors + }, + onSubmitInvalid, + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + await form.handleSubmit() + expect(field.errors).toEqual([{ message: 'Name is required' }]) + expect(form.state.isSubmitSuccessful).toBe(false) + expect(onSubmitInvalid).toHaveBeenCalledOnce() + onSubmitInvalid.mockClear() + + shouldReject = false + expect(field.errors).toEqual([{ message: 'Name is required' }]) + + const result = await form.handleSubmit() + + expect(result).toEqual([]) + expect(field.errors).toEqual([]) + expect(form.state).toMatchObject({ + errors: [], + isValid: true, + isSubmitSuccessful: true, + }) + expect(onSubmitInvalid).not.toHaveBeenCalled() + }) +}) diff --git a/packages/form-core/tests/FormApi/lifecycle.spec.ts b/packages/form-core/tests/FormApi/lifecycle.spec.ts deleted file mode 100644 index 28bd868b35..0000000000 --- a/packages/form-core/tests/FormApi/lifecycle.spec.ts +++ /dev/null @@ -1,953 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' -import { validationSourceScopes } from '../../src/ValidationSourceInstance.lib' -import { installDevtoolsBridge } from '../../src/devtoolsBridge.lib' -import { defaultInternalBaseFieldMeta } from '../../src/FieldApi/fieldState.lib' -import { InternalValidatorInstance } from '../../src/ValidatorInstance.lib' - -describe('form - lifecycle', () => { - describe('initial state', () => { - it('state.values matches defaultValues', () => { - const form = new InternalFormApi({ - defaultValues: { name: 'Alice', age: 30 }, - }) - expect(form.state.values).toEqual({ name: 'Alice', age: 30 }) - }) - - it('state.isTouched starts as false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.state.isTouched).toBe(false) - }) - - it('state.canSubmit starts as true', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.state.canSubmit).toBe(true) - }) - - it('uses a supplied formId', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - - expect(form.formId).toBe('profile-form') - }) - - it('generates and preserves a formId when one is not supplied', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const formId = form.formId - - expect(formId).toBeTypeOf('string') - expect(formId.length).toBeGreaterThan(0) - - form._update({ defaultValues: { name: 'async' } }) - - expect(form.formId).toBe(formId) - }) - - it('supports changing a supplied formId during update', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - - form._update({ - formId: 'renamed-profile-form', - defaultValues: { name: '' }, - }) - - expect(form.formId).toBe('renamed-profile-form') - }) - - it('notifies an installed devtools bridge during form lifecycle hooks', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - const mountForm = vi.fn() - const unmountForm = vi.fn() - const updateForm = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - mountForm, - unmountForm, - updateForm, - }) - - try { - const unmount = form.mount() - expect(mountForm).toHaveBeenCalledWith(form) - - form._update({ - formId: 'renamed-profile-form', - defaultValues: { name: '' }, - }) - expect(updateForm).toHaveBeenCalledWith(form) - - unmount() - expect(unmountForm).toHaveBeenCalledWith(form) - } finally { - uninstallBridge() - } - }) - - it('stops notifying a devtools bridge after it uninstalls', () => { - const form = new InternalFormApi({ - formId: 'profile-form', - defaultValues: { name: '' }, - }) - const mountForm = vi.fn() - const unmountForm = vi.fn() - const updateForm = vi.fn() - const uninstallBridge = installDevtoolsBridge({ - mountForm, - unmountForm, - updateForm, - }) - uninstallBridge() - - const unmount = form.mount() - form._update({ - formId: 'renamed-profile-form', - defaultValues: { name: '' }, - }) - unmount() - - expect(mountForm).not.toHaveBeenCalled() - expect(updateForm).not.toHaveBeenCalled() - expect(unmountForm).not.toHaveBeenCalled() - }) - - it('supports updating defaultValues after initialization', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.state.values).toEqual({ name: '' }) - expect(form.state.isTouched).toBe(false) - form._update({ defaultValues: { name: 'async' } }) - - expect(form.state.isTouched).toBe(false) - expect(form.state.values).toEqual({ name: 'async' }) - }) - - it('preserves touched field values when defaultValues update', () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - form._getOrCreateFieldApi({ name: 'email' }) - - nameField.handleChange('user edit') - form._update({ - defaultValues: { name: 'server name', email: 'server@example.com' }, - }) - - expect(form.state.values).toEqual({ - name: 'user edit', - email: 'server@example.com', - }) - }) - - it('warns when the validator array length changes after initialization', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [{ run: () => null, triggers: [] }], - }) - - form._update({ - defaultValues: { name: '' }, - validators: [ - { run: () => null, triggers: [] }, - { run: () => null, triggers: [] }, - ], - }) - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'length of the validator array should not change', - ), - ) - warn.mockRestore() - }) - - it('keeps form validator instances stable by slot across updates', () => { - const firstDefinition = { run: () => null, triggers: [] } - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [firstDefinition], - }) - const instance = form._validatorInstances?.[0] - const nextDefinition = { run: () => null, triggers: [] } - - form._update({ - defaultValues: { name: '' }, - validators: [nextDefinition], - }) - - expect(form._validatorInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(form) - expect(instance?.scope).toBe(validationSourceScopes.form) - expect(instance?.revision).toBe(1) - }) - - it('keeps form listener instances stable and warns on length changes', () => { - const firstDefinition = { - run: () => {}, - triggers: ['change'] as Array<'change'>, - } - const form = new InternalFormApi({ - defaultValues: { name: '' }, - listeners: [firstDefinition], - }) - const instance = form._listenerInstances?.[0] - const nextDefinition = { - run: () => {}, - triggers: ['blur'] as Array<'blur'>, - } - - form._update({ - defaultValues: { name: '' }, - listeners: [nextDefinition], - }) - - expect(form._listenerInstances?.[0]).toBe(instance) - expect(instance?.definition).toBe(nextDefinition) - expect(instance?.owner).toBe(form) - expect(instance?.index).toBe(0) - expect(instance?.revision).toBe(1) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - form._update({ defaultValues: { name: '' }, listeners: [] }) - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining( - 'length of the listener array should not change', - ), - ) - expect(form._listenerInstances).toBeNull() - expect(instance?.disposed).toBe(true) - warn.mockRestore() - }) - - it('keeps the onSubmit source stable across callback updates', () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const onSubmitSource = form._onSubmitSource - - form._update({ - defaultValues: { name: '' }, - onSubmit: () => null, - }) - expect(form._onSubmitSource).toBe(onSubmitSource) - - form._update({ - defaultValues: { name: '' }, - onSubmit: () => ({ message: 'updated' }), - }) - - expect(form._onSubmitSource).toBe(onSubmitSource) - expect(onSubmitSource.owner).toBe(form) - expect(onSubmitSource.scope).toBe(validationSourceScopes.onSubmit) - expect(onSubmitSource.index).toBe(0) - }) - - it('resets form validator runtime without replacing its instance', () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [{ run: () => null, triggers: [] }], - }) - const instance = form._validatorInstances?.[0] - const abortController = new AbortController() - instance?.setAbortController(abortController) - instance?.setOutput({ - output: 'output', - hasOutput: true, - }) - - form.reset() - - expect(form._validatorInstances?.[0]).toBe(instance) - expect(abortController.signal.aborted).toBe(true) - expect(instance?.hasOutput).toBe(false) - expect(instance?.disposed).toBe(false) - }) - - it('should only apply options to the leaf node', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const form = new InternalFormApi({ - defaultValues: { foo: { bar: '' } }, - }) - const field = form._getOrCreateFieldApi({ - name: 'foo.bar', - listeners: [{ run: listener, triggers: ['change'] }], - }) - - field.handleChange('New value') - - await vi.runAllTimersAsync() - - expect(listener).toHaveBeenCalledOnce() - }) - - // TODO extend with default state - }) - - describe('default options', () => { - it('resolves defaults during construction and updates', () => { - const calls: Array = [] - const form = new InternalFormApi( - { - defaultValues: { name: '' }, - listeners: [ - { triggers: ['change'], run: () => calls.push('incoming') }, - ], - }, - { - form: { - listenersMerge: 'append', - listeners: [ - { triggers: ['change'], run: () => calls.push('default') }, - ], - }, - }, - ) - - form.setFieldValue('name', 'initial') - expect(calls).toEqual(['default', 'incoming']) - - calls.length = 0 - form._update({ - defaultValues: { name: '' }, - listeners: [{ triggers: ['change'], run: () => calls.push('updated') }], - }) - form.setFieldValue('name', 'updated') - expect(calls).toEqual(['default', 'updated']) - }) - }) - - describe('reset', () => { - it('resets form state', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - - field.handleChange('bye') - form.reset() - expect(form.state.values).toEqual({ name: '' }) - }) - - it('resets form state and updated default values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - - field.handleChange('bye') - form.reset({ name: 'reset default' }) - expect(form.state.values).toEqual({ name: 'reset default' }) - expect(form.defaultValues).toEqual({ name: 'reset default' }) - }) - - it('resets form state and does not update default values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - - field.handleChange('bye') - form.reset({ name: 'reset state' }, { updateDefaultValues: false }) - expect(form.state.values).toEqual({ name: 'reset state' }) - expect(form.defaultValues).toEqual({ name: '' }) - }) - - it('does not let a repeated _update default overwrite reset values', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form._update({ defaultValues: { name: 'async' } }) - form.reset({ name: 'reset default' }) - form._update({ defaultValues: { name: 'async' } }) - - expect(form.state.values).toEqual({ name: 'reset default' }) - expect(form.defaultValues).toEqual({ name: 'reset default' }) - expect(form.state.isDefaultValue).toBe(true) - - form._update({ defaultValues: { name: 'new async' } }) - - expect(form.state.values).toEqual({ name: 'new async' }) - expect(form.defaultValues).toEqual({ name: 'new async' }) - expect(form.state.isDefaultValue).toBe(true) - }) - - it('resets meta and kills fields', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - run: () => 'Submit error', - triggers: [], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - field.handleChange('dirty') - - await form.handleSubmit() - form.reset() - - expect(form.state.values).toEqual({ name: '' }) - expect(form.state.isDirty).toBe(false) - expect(form.state.isTouched).toBe(false) - expect(form.state.errors).toEqual([]) - expect(form.state.submissionAttempts).toBe(0) - expect(form.getFieldMeta('name')).toBeUndefined() - }) - - it('cancels pending form validators on reset', async () => { - vi.useFakeTimers() - const validator = vi.fn(() => 'Late error') - const form = new InternalFormApi({ - defaultValues: { name: '' }, - validators: [ - { - triggers: ['change'], - triggerDebounceMs: 100, - run: validator, - }, - ], - }) - - const validatePromise = form.validate('change') - form.reset() - await vi.advanceTimersByTimeAsync(100) - const result = await validatePromise - - expect(result).toEqual([]) - expect(validator).not.toHaveBeenCalled() - expect(form.state.errors).toEqual([]) - }) - - it('cancels pending field listeners on reset', async () => { - vi.useFakeTimers() - const listener = vi.fn() - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ - name: 'name', - listeners: [ - { - triggers: ['change'], - triggerDebounceMs: 100, - run: listener, - }, - ], - }) - - field.handleChange('dirty') - form.reset() - await vi.advanceTimersByTimeAsync(100) - - expect(listener).not.toHaveBeenCalled() - }) - - it('ignores submit cleanup and submit errors after reset', async () => { - let finishSubmit!: () => void - const form = new InternalFormApi({ - defaultValues: { name: '' }, - onSubmit: ({ createValidationError }) => - new Promise((resolve) => { - finishSubmit = () => - resolve(createValidationError('Late submit error')) - }), - }) - - const submitPromise = form.handleSubmit() - await vi.waitFor(() => { - expect(finishSubmit).toBeTypeOf('function') - }) - - form.reset({ name: 'reset value' }, { updateDefaultValues: false }) - finishSubmit() - const result = await submitPromise - - expect(result).toEqual([]) - expect(form.state.values).toEqual({ name: 'reset value' }) - expect(form.state.errors).toEqual([]) - expect(form.state.isSubmitting).toBe(false) - expect(form.state.submissionAttempts).toBe(0) - }) - }) - - // field methods - - describe('resetField', () => { - it('should reset field', () => { - const form = new InternalFormApi({ defaultValues: { name: 'hi' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - const validatorInstance = new InternalValidatorInstance({ - definition: { run: () => null, triggers: [] }, - owner: field, - scope: 'field', - }) - field.handleChange('bye') - field.handleBlur() - field._setMeta((prev) => ({ - ...prev, - isValidating: true, - _validationCount: 1, - _arrayVersion: 1, - _validationSourceErrors: new Map([ - [ - validatorInstance, - { errors: [{ message: 'Reset me' }], sourceEvent: 'change' }, - ], - ]), - })) - - expect(field._getBaseMeta()).not.toBe(defaultInternalBaseFieldMeta) - - form.resetField('name') - - expect(form.getFieldValue('name')).toEqual('hi') - expect(field._getBaseMeta()).toBe(defaultInternalBaseFieldMeta) - }) - - it('recursively resets descendant fields without replacing them', () => { - const form = new InternalFormApi({ - defaultValues: { person: { name: 'Alice' } }, - }) - const parent = form._getOrCreateFieldApi({ name: 'person' }) - const child = form._getOrCreateFieldApi({ name: 'person.name' }) - parent._register() - child._register() - - child.handleChange('Bob') - child.handleBlur() - - expect(parent._getBaseMeta()).not.toBe(defaultInternalBaseFieldMeta) - expect(child._getBaseMeta()).not.toBe(defaultInternalBaseFieldMeta) - - form.resetField('person') - - expect(form.getFieldValue('person')).toEqual({ name: 'Alice' }) - expect(form._tryGetFieldApi('person')).toBe(parent) - expect(form._tryGetFieldApi('person.name')).toBe(child) - expect(parent._isKilled).toBe(false) - expect(child._isKilled).toBe(false) - expect(parent._isMounted).toBe(true) - expect(child._isMounted).toBe(true) - expect(parent._getBaseMeta()).toBe(defaultInternalBaseFieldMeta) - expect(child._getBaseMeta()).toBe(defaultInternalBaseFieldMeta) - expect(form.state.isTouched).toBe(false) - }) - - it('deletes a field through an explicit field override', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - form.deleteField('name', { fieldApiOverride: field }) - - expect(form._tryGetFieldApi('name')).toBeNull() - }) - - it('ignores deleting a field that has not been created', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.deleteField('name') - - expect(form.state.values).toEqual({ name: '' }) - }) - }) - - describe('event error cleanup', () => { - it('clears form-level submit errors when any field changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError('Submission failed'), - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - expect.objectContaining({ message: 'Submission failed' }), - ]) - expect( - form._atoms.meta.formErrors - .get() - .validationSourceErrors?.get(form._onSubmitSource)?.errors, - ).toEqual([expect.objectContaining({ message: 'Submission failed' })]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - expect( - form._atoms.meta.formErrors - .get() - .validationSourceErrors?.get(form._onSubmitSource), - ).toBeUndefined() - }) - - it('clears form-level submit errors when any field blurs', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError('Submission failed'), - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - expect.objectContaining({ message: 'Submission failed' }), - ]) - - field.handleBlur() - - expect(form.state.errors).toEqual([]) - }) - - it('clears form-level submit-only validator errors when any field changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Submit-only validator failed', - triggers: [], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit-only validator failed' }, - ]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - }) - - it('clears form-level submit validator errors when the validator does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Submit or blur validator failed', - triggers: ['blur'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit or blur validator failed' }, - ]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - }) - - it('does not clear non-submit-sourced validator errors on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Blur validator failed', - triggers: ['blur'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.validate('blur') - expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([{ message: 'Blur validator failed' }]) - }) - - it('does not event-clear submit errors when the validator runs on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: ({ event }) => - event === 'submit' - ? 'Submit validator failed' - : 'Change validator failed', - triggers: ['change'], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit validator failed' }, - ]) - - field.handleChange('Alice') - - await vi.waitFor(() => { - expect(form.state.errors).toEqual([ - { message: 'Change validator failed' }, - ]) - }) - }) - - it('clears submit errors when the validator conditionally does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - validators: [ - { - run: () => 'Submit validator failed', - triggers: [{ trigger: 'change', when: false }], - }, - ], - }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - - await form.handleSubmit() - expect(form.state.errors).toEqual([ - { message: 'Submit validator failed' }, - ]) - - field.handleChange('Alice') - - expect(form.state.errors).toEqual([]) - }) - - it('only clears field-level submit errors for the field that changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError({ - fields: { - name: 'Name is required', - email: 'Email is required', - }, - }), - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const emailField = form._getOrCreateFieldApi({ name: 'email' }) - void nameField.atom - void emailField.atom - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - expect( - nameField - ._getBaseMeta() - ._validationSourceErrors?.get(form._onSubmitSource)?.errors, - ).toEqual([{ message: 'Name is required' }]) - expect(form._onSubmitSource.errorTargets).toEqual( - new Set([nameField, emailField]), - ) - - nameField.handleChange('Alice') - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - expect(form._onSubmitSource.errorTargets).toEqual(new Set([emailField])) - }) - - it('only clears field-level submit errors for the field that blurs', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - onSubmit: ({ createValidationError }) => - createValidationError({ - fields: { - name: 'Name is required', - email: 'Email is required', - }, - }), - }) - const nameField = form._getOrCreateFieldApi({ name: 'name' }) - const emailField = form._getOrCreateFieldApi({ name: 'email' }) - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - - nameField.handleBlur() - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - }) - - it('clears field-level submit-only validator errors for the field that changes', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - }) - const nameField = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Name is required', - triggers: [], - }, - ], - }) - const emailField = form._getOrCreateFieldApi({ - name: 'email', - validators: [ - { - run: () => 'Email is required', - triggers: [], - }, - ], - }) - void nameField.atom - void emailField.atom - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - - nameField.handleChange('Alice') - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - }) - - it('clears field-level submit validator errors when the validator does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '', email: '' }, - }) - const nameField = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Name is required', - triggers: ['blur'], - }, - ], - }) - const emailField = form._getOrCreateFieldApi({ - name: 'email', - validators: [ - { - run: () => 'Email is required', - triggers: ['blur'], - }, - ], - }) - void nameField.atom - void emailField.atom - - await form.handleSubmit() - expect(nameField.errors).toEqual([{ message: 'Name is required' }]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - - nameField.handleChange('Alice') - - expect(nameField.errors).toEqual([]) - expect(emailField.errors).toEqual([{ message: 'Email is required' }]) - }) - - it('does not clear non-submit-sourced field validator errors on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Name is required', - triggers: ['blur'], - }, - ], - }) - void field.atom - - await field._runFieldValidation('blur') - expect(field.errors).toEqual([{ message: 'Name is required' }]) - - field.handleChange('Alice') - - expect(field.errors).toEqual([{ message: 'Name is required' }]) - }) - - it('does not event-clear field validator errors when the validator runs on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: ({ event }: { event: string }) => - event === 'submit' - ? 'Submit field validator failed' - : 'Change field validator failed', - triggers: ['change'], - }, - ], - }) - void field.atom - - await form.handleSubmit() - expect(field.errors).toEqual([ - { message: 'Submit field validator failed' }, - ]) - - field.handleChange('Alice') - - await vi.waitFor(() => { - expect(field.errors).toEqual([ - { message: 'Change field validator failed' }, - ]) - }) - }) - - it('clears field submit errors when the validator conditionally does not run on change', async () => { - const form = new InternalFormApi({ - defaultValues: { name: '' }, - }) - const field = form._getOrCreateFieldApi({ - name: 'name', - validators: [ - { - run: () => 'Submit field validator failed', - triggers: [{ trigger: 'change', when: false }], - }, - ], - }) - void field.atom - - await form.handleSubmit() - expect(field.errors).toEqual([ - { message: 'Submit field validator failed' }, - ]) - - field.handleChange('Alice') - - expect(field.errors).toEqual([]) - }) - }) - - describe('deleteField ', () => { - it('unmounts the field atom', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - field._register() - expect(field._isMounted).toBe(true) - form.deleteField('name', { fieldApiOverride: field }) - expect(field._isMounted).toBe(false) - }) - - it('also unmounts child field atoms', () => { - const form = new InternalFormApi({ defaultValues: { a: { b: '' } } }) - const parent = form._getOrCreateFieldApi({ name: 'a' }) - const child = form._getOrCreateFieldApi({ name: 'a.b' }) - void parent.atom - void child.atom - form.deleteField('a', { fieldApiOverride: parent }) - expect(parent._isMounted).toBe(false) - expect(child._isMounted).toBe(false) - }) - }) -}) diff --git a/packages/form-core/tests/devtoolsBridge.lib.spec.ts b/packages/form-core/tests/devtoolsBridge.lib.spec.ts new file mode 100644 index 0000000000..8ef8f80381 --- /dev/null +++ b/packages/form-core/tests/devtoolsBridge.lib.spec.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../src/FormApi/FormApi.lib' +import { installDevtoolsBridge } from '../src/devtoolsBridge.lib' + +describe('installDevtoolsBridge', () => { + it('reports a field mount once and unmounts it after its last consumer unregisters', async ({ + onTestFinished, + }) => { + vi.useFakeTimers() + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const mountField = vi.fn() + const unmountField = vi.fn() + const uninstall = installDevtoolsBridge({ mountField, unmountField }) + onTestFinished(uninstall) + + const unregisterFirst = field._register() + const unregisterSecond = field._register() + + expect(mountField).toHaveBeenCalledOnce() + expect(mountField).toHaveBeenCalledWith(field) + + unregisterFirst() + expect(unmountField).not.toHaveBeenCalled() + + unregisterSecond() + await vi.runOnlyPendingTimersAsync() + + expect(unmountField).toHaveBeenCalledOnce() + expect(unmountField).toHaveBeenCalledWith(field, 'name') + }) + + it('reports moved fields with their previous paths after an array swap', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b'] }, + }) + const first = form._getOrCreateFieldApi({ name: 'items[0]' }) + const second = form._getOrCreateFieldApi({ name: 'items[1]' }) + const moveField = vi.fn() + const uninstall = installDevtoolsBridge({ moveField }) + onTestFinished(uninstall) + + form.swapFieldValues('items', 0, 1) + + expect(moveField).toHaveBeenCalledWith(first, 'items[0]') + expect(moveField).toHaveBeenCalledWith(second, 'items[1]') + expect(first.name).toBe('items[1]') + expect(second.name).toBe('items[0]') + expect(form.state.values.items).toEqual(['b', 'a']) + }) + + it('reports updates for a changed field and its parent even when both are already dirty', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { user: { name: '' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const field = form._getOrCreateFieldApi({ name: 'user.name' }) + const updateField = vi.fn() + const uninstall = installDevtoolsBridge({ updateField }) + onTestFinished(uninstall) + + field.handleChange('first') + + expect(field.meta.isDirty).toBe(true) + expect(parent.meta.isDirty).toBe(true) + expect(updateField).toHaveBeenCalledWith(field) + expect(updateField).toHaveBeenCalledWith(parent) + updateField.mockClear() + + field.handleChange('second') + + expect(field.meta.isDirty).toBe(true) + expect(parent.meta.isDirty).toBe(true) + expect(field.value).toBe('second') + expect(parent.value).toEqual({ name: 'second' }) + expect(updateField).toHaveBeenCalledWith(field) + expect(updateField).toHaveBeenCalledWith(parent) + }) + + it('reports field resets and updated defaults that bypass change events', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + const updateField = vi.fn() + const uninstall = installDevtoolsBridge({ updateField }) + onTestFinished(uninstall) + + field.handleChange('changed') + updateField.mockClear() + + form.resetField('name') + + expect(field.value).toBe('') + expect(updateField).toHaveBeenCalledWith(field) + updateField.mockClear() + + form._update({ defaultValues: { name: 'new default' } }) + + expect(field.value).toBe('new default') + expect(updateField).toHaveBeenCalledWith(field) + }) + + it('reports every removed field in a subtree with its previous path', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { user: { name: '' } }, + }) + const parent = form._getOrCreateFieldApi({ name: 'user' }) + const child = form._getOrCreateFieldApi({ name: 'user.name' }) + const removeFieldSubtree = vi.fn() + const uninstall = installDevtoolsBridge({ removeFieldSubtree }) + onTestFinished(uninstall) + + form.deleteField('user') + + expect(removeFieldSubtree).toHaveBeenCalledOnce() + expect(removeFieldSubtree).toHaveBeenCalledWith(form, [ + { field: parent, previousPath: 'user' }, + { field: child, previousPath: 'user.name' }, + ]) + }) + + it('reports fields added beneath an existing parent', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { user: { name: 'Alice' } }, + }) + form._getOrCreateFieldApi({ name: 'user' }) + const fieldAdded = vi.fn() + const uninstall = installDevtoolsBridge({ fieldAdded }) + onTestFinished(uninstall) + + const child = form._getOrCreateFieldApi({ name: 'user.name' }) + + expect(fieldAdded).toHaveBeenCalledWith(child) + expect(child.name).toBe('user.name') + expect(child.value).toBe('Alice') + }) + + it('reports listener dependency endpoints when attached and when the watching field is deleted', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { source: '', target: 'target' }, + }) + const source = form._getOrCreateFieldApi({ name: 'source' }) + const target = form._getOrCreateFieldApi({ name: 'target' }) + source._register() + target._register() + const listener = vi.fn() + const fieldDependenciesChanged = vi.fn() + const uninstall = installDevtoolsBridge({ fieldDependenciesChanged }) + onTestFinished(uninstall) + + target._update({ + listeners: [ + { triggers: ['change'], watchFields: ['source'], run: listener }, + ], + }) + + expect(fieldDependenciesChanged).toHaveBeenCalledWith([ + expect.objectContaining({ + kind: 'listener', + sourceField: source, + watchingField: target, + listenerInstance: expect.anything(), + }), + ]) + + source.handleChange('first') + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ value: 'target', fieldApi: target }), + ) + listener.mockClear() + fieldDependenciesChanged.mockClear() + + form.deleteField('target') + + expect(fieldDependenciesChanged).toHaveBeenCalledWith([ + expect.objectContaining({ + kind: 'listener', + sourceField: source, + watchingField: target, + listenerInstance: expect.anything(), + }), + ]) + + source.handleChange('after deletion') + expect(listener).not.toHaveBeenCalled() + }) + + it('receives form mount, update, and unmount notifications', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + const mountForm = vi.fn() + const unmountForm = vi.fn() + const updateForm = vi.fn() + const uninstall = installDevtoolsBridge({ + mountForm, + unmountForm, + updateForm, + }) + onTestFinished(uninstall) + + const unmount = form.mount() + onTestFinished(unmount) + expect(mountForm).toHaveBeenCalledWith(form) + + form._update({ + formId: 'renamed-profile-form', + defaultValues: { name: '' }, + }) + + expect(updateForm).toHaveBeenCalledWith(form) + + unmount() + + expect(unmountForm).toHaveBeenCalledWith(form) + }) + + it('stops lifecycle notifications after the bridge is uninstalled', ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + formId: 'profile-form', + defaultValues: { name: '' }, + }) + const mountForm = vi.fn() + const unmountForm = vi.fn() + const updateForm = vi.fn() + const uninstall = installDevtoolsBridge({ + mountForm, + unmountForm, + updateForm, + }) + onTestFinished(uninstall) + + const unmount = form.mount() + onTestFinished(unmount) + expect(mountForm).toHaveBeenCalledWith(form) + mountForm.mockClear() + + uninstall() + + const unmountAfterUninstall = form.mount() + onTestFinished(unmountAfterUninstall) + form._update({ + formId: 'renamed-profile-form', + defaultValues: { name: '' }, + }) + unmount() + unmountAfterUninstall() + + expect(mountForm).not.toHaveBeenCalled() + expect(updateForm).not.toHaveBeenCalled() + expect(unmountForm).not.toHaveBeenCalled() + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 9ad8c64133..c495877a1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["DOM", "DOM.Iterable", "ES2022"], + "lib": ["DOM", "DOM.Iterable", "ES2022", "ES2024.Promise"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, From ee7b366f99c2c02ac270b10caa777b67df059134 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:58:26 +0200 Subject: [PATCH 16/43] chore: migrate field-state.spec.ts --- .../tests/FormApi/FormApi.lib.spec.ts | 342 ++++++++++++++++++ .../tests/FormApi/field-state.spec.ts | 151 -------- 2 files changed, 342 insertions(+), 151 deletions(-) delete mode 100644 packages/form-core/tests/FormApi/field-state.spec.ts diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index 834da29cc9..af5c60051b 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -269,6 +269,348 @@ describe('InternalFormApi - _getOrCreateFieldApi', () => { }) }) +describe('InternalFormApi - getFieldValue', () => { + it('reads values through top-level, nested object, and array paths', () => { + const form = new InternalFormApi({ + defaultValues: { + name: 'Alice', + address: { city: 'London' }, + items: ['a', 'b', 'c'], + }, + }) + + const name = form.getFieldValue('name') + const city = form.getFieldValue('address.city') + const item = form.getFieldValue('items[1]') + + expect(name).toBe('Alice') + expect(city).toBe('London') + expect(item).toBe('b') + }) + + it('returns undefined for missing paths and paths through null', () => { + const form = new InternalFormApi({ defaultValues: { user: null } }) + + const missing = form.getFieldValue('missing') + const name = form.getFieldValue('user.name') + + expect(missing).toBeUndefined() + expect(name).toBeUndefined() + }) +}) + +describe('InternalFormApi - setFieldValue', () => { + it('updates a nested value without changing unrelated values or defaults', () => { + const form = new InternalFormApi({ + defaultValues: { + name: 'Alice', + address: { city: 'London', country: 'UK' }, + }, + }) + + form.setFieldValue('address.city', 'Manchester') + + const city = form.getFieldValue('address.city') + expect(city).toBe('Manchester') + expect(form.state.values).toEqual({ + name: 'Alice', + address: { city: 'Manchester', country: 'UK' }, + }) + expect(form.defaultValues).toEqual({ + name: 'Alice', + address: { city: 'London', country: 'UK' }, + }) + }) + + it('updates a nested array value without changing other items or defaults', () => { + const form = new InternalFormApi({ + defaultValues: { items: [{ label: 'a' }, { label: 'b' }] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.setFieldValue('items[0].label', 'updated') + + const label = form.getFieldValue('items[0].label') + expect(label).toBe('updated') + expect(field.value).toEqual([{ label: 'updated' }, { label: 'b' }]) + expect(form.defaultValues).toEqual({ + items: [{ label: 'a' }, { label: 'b' }], + }) + }) + + it('marks the field and form touched and dirty when a value changes', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + form.setFieldValue('name', 'Alice') + + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: true, + isDirty: true, + isPristine: false, + }) + }) + + it('keeps the field and form untouched when markAsTouched is false', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + form.setFieldValue('name', 'Alice', { markAsTouched: false }) + + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: true, + isPristine: false, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: true, + isPristine: false, + }) + }) + + it('keeps the field and form pristine when markAsDirty is false', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + form.setFieldValue('name', 'Alice', { markAsDirty: false }) + + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: false, + isPristine: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: true, + isDirty: false, + isPristine: true, + }) + }) + + it('reveals field errors when markAsBlurred is true on a value update', async () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice' }, + errorVisibility: ({ fieldState }) => fieldState.meta.isBlurred, + }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ value }: { value: string }) => + value.length < 3 + ? 'Name must have at least 3 characters' + : undefined, + }, + ], + }) + field._register() + + form.setFieldValue('name', 'A') + + await expect + .poll(() => field.meta.original.errors) + .toEqual([{ message: 'Name must have at least 3 characters' }]) + expect(field.meta.isBlurred).toBe(false) + expect(field.errors).toEqual([]) + + form.setFieldValue('name', 'Al', { markAsBlurred: true }) + + expect(field.value).toBe('Al') + expect(field.meta.isBlurred).toBe(true) + await expect + .poll(() => field.errors) + .toEqual([{ message: 'Name must have at least 3 characters' }]) + }) + + it('preserves existing touched, dirty, and blurred state when metadata flags are false', () => { + const form = new InternalFormApi({ defaultValues: { name: '' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field.handleChange('Alice') + field.handleBlur() + + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + isBlurred: true, + }) + expect(form.state).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + }) + + form.setFieldValue('name', 'Bob', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(field.value).toBe('Bob') + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + isBlurred: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Bob' }, + isTouched: true, + isDirty: true, + isPristine: false, + }) + }) + + it('continues field and form validation when all metadata flags are false', async () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice' }, + validators: [ + { + triggers: ['change'], + run: ({ value }) => + value.name.length < 3 ? 'Form name is too short' : undefined, + }, + ], + }) + const field = form._getOrCreateFieldApi({ + name: 'name', + validators: [ + { + triggers: ['change'], + run: ({ value }: { value: string }) => + value.length < 3 ? 'Field name is too short' : undefined, + }, + ], + }) + field._register() + + form.setFieldValue('name', 'Al', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + await expect + .poll(() => field.errors) + .toEqual([{ message: 'Field name is too short' }]) + await expect + .poll(() => form.state.errors) + .toEqual([{ message: 'Form name is too short' }]) + expect(field.value).toBe('Al') + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + isPristine: true, + isBlurred: false, + isValid: false, + }) + expect(form.state).toMatchObject({ + values: { name: 'Al' }, + isTouched: false, + isDirty: false, + isPristine: true, + isValid: false, + }) + + form.setFieldValue('name', 'Alice', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + await expect.poll(() => field.errors).toEqual([]) + await expect.poll(() => form.state.errors).toEqual([]) + expect(field.value).toBe('Alice') + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + isPristine: true, + isBlurred: false, + isValid: true, + }) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + isPristine: true, + isValid: true, + }) + }) + + // TODO: Form validation is skipped when disabled metadata updates prevent field creation. + it.fails( + 'validates form changes before field creation when all metadata flags are false', + async () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice' }, + validators: [ + { + triggers: ['change'], + run: ({ value }) => + value.name.length < 3 ? 'Name is too short' : undefined, + }, + ], + }) + + form.setFieldValue('name', 'Al', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + await expect + .poll(() => form.state.errors) + .toEqual([{ message: 'Name is too short' }]) + }, + ) + + it('applies updater functions to the current field value', () => { + const form = new InternalFormApi({ defaultValues: { count: 1 } }) + + form.setFieldValue('count', (count: number) => count + 1) + + const firstUpdate = form.getFieldValue('count') + expect(firstUpdate).toBe(2) + + form.setFieldValue('count', (count: number) => count + 1) + + const secondUpdate = form.getFieldValue('count') + expect(secondUpdate).toBe(3) + }) + + it('replaces array values directly and through an updater', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.setFieldValue('items', ['b']) + + const replacement = form.getFieldValue('items') + expect(replacement).toEqual(['b']) + expect(field.value).toEqual(['b']) + + form.setFieldValue('items', (items: Array) => + items.map((item) => item.toUpperCase()), + ) + + const updated = form.getFieldValue('items') + expect(updated).toEqual(['B']) + expect(field.value).toEqual(['B']) + expect(form.defaultValues).toEqual({ items: ['a'] }) + }) +}) + describe('InternalFormApi - reset', () => { it('notifies parent and child reset listeners with their values before removing the fields', () => { const parentListener = vi.fn() diff --git a/packages/form-core/tests/FormApi/field-state.spec.ts b/packages/form-core/tests/FormApi/field-state.spec.ts deleted file mode 100644 index 970c6e65c7..0000000000 --- a/packages/form-core/tests/FormApi/field-state.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('form - field state', () => { - describe('getFieldValue', () => { - it('returns a top-level value', () => { - const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) - expect(form.getFieldValue('name')).toBe('Alice') - }) - - it('returns a nested value using dot notation', () => { - const form = new InternalFormApi({ - defaultValues: { address: { city: 'London' } }, - }) - expect(form.getFieldValue('address.city')).toBe('London') - }) - - it('returns an array element using bracket notation', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - expect(form.getFieldValue('items[1]')).toBe('b') - }) - - it('returns undefined for a path that does not exist', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - expect(form.getFieldValue('nonexistent')).toBeUndefined() - }) - - it('returns undefined when a nested path traverses through null', () => { - const form = new InternalFormApi({ defaultValues: { user: null } }) - expect(form.getFieldValue('user.name')).toBeUndefined() - }) - }) - - describe('setFieldValue', () => { - it('updates a top-level value', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.setFieldValue('name', 'Alice') - expect(form.getFieldValue('name')).toBe('Alice') - }) - - it('updates a nested value without mutating unrelated keys', () => { - const form = new InternalFormApi({ - defaultValues: { address: { city: 'London', country: 'UK' } }, - }) - form.setFieldValue('address.city', 'Manchester') - expect(form.getFieldValue('address.city')).toBe('Manchester') - expect(form.getFieldValue('address.country')).toBe('UK') - }) - - it('accepts an updater function', () => { - const form = new InternalFormApi({ defaultValues: { count: 1 } }) - form.setFieldValue('count', (prev: number) => prev + 1) - expect(form.getFieldValue('count')).toBe(2) - }) - - it('increments the array version for a same-length array replacement', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - form.setFieldValue('items', ['b']) - - expect(form.getFieldValue('items')).toEqual(['b']) - expect(field.meta._arrayVersion).toBe(1) - }) - - it('increments the array version for an updater replacement', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - form.setFieldValue('items', (items: Array) => - items.map((item) => item.toUpperCase()), - ) - - expect(form.getFieldValue('items')).toEqual(['A']) - expect(field.meta._arrayVersion).toBe(1) - }) - - it('does not increment a parent array version for a nested field update', () => { - const form = new InternalFormApi({ - defaultValues: { items: [{ label: 'a' }] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - form.setFieldValue('items[0].label', 'b') - - expect(form.getFieldValue('items')).toEqual([{ label: 'b' }]) - expect(field.meta._arrayVersion).toBe(0) - }) - - it('increments the array version only once for array helpers', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - - form.swapFieldValues('items', 0, 1) - expect(field.meta._arrayVersion).toBe(1) - - form.moveFieldValue('items', 0, 2) - expect(field.meta._arrayVersion).toBe(2) - - form.clearFieldValues('items') - expect(field.meta._arrayVersion).toBe(2) - - form.clearFieldValues('items') - expect(field.meta._arrayVersion).toBe(3) - }) - - it('marks form isTouched and isDirty after a change', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - form.setFieldValue('name', 'Alice', { fieldApiOverride: field }) - expect(form.state.isTouched).toBe(true) - expect(form.state.isDirty).toBe(true) - expect(form.state.isPristine).toBe(false) - }) - - it('does not mark form isTouched when markAsTouched is false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - form.setFieldValue('name', 'Alice', { - fieldApiOverride: field, - markAsTouched: false, - }) - expect(form.state.isTouched).toBe(false) - }) - - it('does not mark form isDirty when markAsDirty is false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - form.setFieldValue('name', 'Alice', { - fieldApiOverride: field, - markAsDirty: false, - }) - expect(form.state.isDirty).toBe(false) - expect(form.state.isPristine).toBe(true) - }) - - it('does not mark the field dirty when markAsDirty is false', () => { - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - form.setFieldValue('name', 'Alice', { - fieldApiOverride: field, - markAsDirty: false, - }) - expect(field.meta.isDirty).toBe(false) - }) - }) -}) From c889e7b7911fd279dfe9cd1ba2d0436cf9fe37b7 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:55:31 +0200 Subject: [PATCH 17/43] chore: migrate array-methods unit tests --- .../tests/FormApi/array-methods.lib.spec.ts | 1195 +++++++++++++++++ .../tests/FormApi/field-array-methods.spec.ts | 640 --------- .../tests/FormApi/field-meta.spec.ts | 16 - 3 files changed, 1195 insertions(+), 656 deletions(-) create mode 100644 packages/form-core/tests/FormApi/array-methods.lib.spec.ts delete mode 100644 packages/form-core/tests/FormApi/field-array-methods.spec.ts delete mode 100644 packages/form-core/tests/FormApi/field-meta.spec.ts diff --git a/packages/form-core/tests/FormApi/array-methods.lib.spec.ts b/packages/form-core/tests/FormApi/array-methods.lib.spec.ts new file mode 100644 index 0000000000..582ee86dbc --- /dev/null +++ b/packages/form-core/tests/FormApi/array-methods.lib.spec.ts @@ -0,0 +1,1195 @@ +import { describe, expect, it, vi } from 'vitest' +import { InternalFormApi } from '../../src/FormApi/FormApi.lib' + +describe('InternalFormApi - swapFieldValues', () => { + it('swaps items and keeps existing nested fields attached to their items', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + + form.swapFieldValues('items', 0, 2) + + expect(form.state.values).toEqual({ + items: [{ name: 'Carol' }, { name: 'Bob' }, { name: 'Alice' }], + }) + expect(name0.name).toBe('items[2].name') + expect(name0.value).toBe('Alice') + expect(name2.name).toBe('items[0].name') + expect(name2.value).toBe('Carol') + + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alicia') + name2.handleChange('Caroline') + + expect(form.state.values).toEqual({ + items: [{ name: 'Caroline' }, { name: 'Bob' }, { name: 'Alicia' }], + }) + }) + + it('leaves values and interaction state unchanged when both indices are equal', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.swapFieldValues('items', 1, 1) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.swapFieldValues('name', 0, 1) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('swapFieldValues')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('swaps values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + + form.swapFieldValues('items', 0, 1, { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['b', 'a'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - moveFieldValue', () => { + it('moves an item forward and keeps existing nested fields attached to their items', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [ + { name: 'Alice' }, + { name: 'Bob' }, + { name: 'Carol' }, + { name: 'Dan' }, + ], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item1 = form._getOrCreateFieldApi({ name: 'items[1]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const item3 = form._getOrCreateFieldApi({ name: 'items[3]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + const name3 = form._getOrCreateFieldApi({ name: 'items[3].name' }) + + // Before: 0 1 2 3 + // After: 1 2 0 3 + // Fields: name1 name2 name0 name3 + form.moveFieldValue('items', 0, 2) + + expect(form.state.values).toEqual({ + items: [ + { name: 'Bob' }, + { name: 'Carol' }, + { name: 'Alice' }, + { name: 'Dan' }, + ], + }) + expect(name0.name).toBe('items[2].name') + expect(name0.value).toBe('Alice') + expect(name1.name).toBe('items[0].name') + expect(name1.value).toBe('Bob') + expect(name2.name).toBe('items[1].name') + expect(name2.value).toBe('Carol') + expect(name3.name).toBe('items[3].name') + expect(name3.value).toBe('Dan') + + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item1.meta).toMatchObject(untouchedMeta) + expect(name1.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + expect(item3.meta).toMatchObject(untouchedMeta) + expect(name3.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alicia') + name1.handleChange('Bobby') + name2.handleChange('Caroline') + name3.handleChange('Daniel') + + expect(form.state.values).toEqual({ + items: [ + { name: 'Bobby' }, + { name: 'Caroline' }, + { name: 'Alicia' }, + { name: 'Daniel' }, + ], + }) + }) + + it('moves an item backward while preserving the other items in order', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c', 'd'] }, + }) + + form.moveFieldValue('items', 2, 0) + + expect(form.state.values).toEqual({ items: ['c', 'a', 'b', 'd'] }) + }) + + it('leaves values and interaction state unchanged when both indices are equal', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.moveFieldValue('items', 1, 1) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.moveFieldValue('name', 0, 1) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('moveFieldValue')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for an out-of-bounds move', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.moveFieldValue('items', 0, 2) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('moves values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + + form.moveFieldValue('items', 0, 2, { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['b', 'c', 'a'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - pushFieldValue', () => { + it('marks only the array field touched and dirty when appending an item', () => { + const form = new InternalFormApi({ + defaultValues: { items: [{ name: 'Alice' }, { name: 'Bob' }] }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item1 = form._getOrCreateFieldApi({ name: 'items[1]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + + form.pushFieldValue('items', { name: 'Carol' }) + + expect(form.state.values).toEqual({ + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }) + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item1.meta).toMatchObject(untouchedMeta) + expect(name1.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + }) + + it('warns and preserves values and interaction state when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + + form.pushFieldValue('name', 'Bob') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('pushFieldValue')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) +}) + +describe('InternalFormApi - insertFieldValue', () => { + it('inserts an item at the beginning without changing the order of existing items', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + + form.insertFieldValue('items', 0, 'x') + + expect(form.state.values).toEqual({ items: ['x', 'a', 'b'] }) + }) + + it('inserts an item between existing items and keeps nested fields attached to their items', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item1 = form._getOrCreateFieldApi({ name: 'items[1]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + + // Before: 0 1 2 + // After: 0 x 1 2 + // Fields: name0 - name1 name2 + form.insertFieldValue('items', 1, { name: 'Dan' }) + + expect(form.state).toMatchObject({ + values: { + items: [ + { name: 'Alice' }, + { name: 'Dan' }, + { name: 'Bob' }, + { name: 'Carol' }, + ], + }, + isTouched: true, + isDirty: true, + isPristine: false, + }) + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + }) + expect(name0.name).toBe('items[0].name') + expect(name0.value).toBe('Alice') + expect(name1.name).toBe('items[2].name') + expect(name1.value).toBe('Bob') + expect(name2.name).toBe('items[3].name') + expect(name2.value).toBe('Carol') + + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item1.meta).toMatchObject(untouchedMeta) + expect(name1.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alicia') + name1.handleChange('Bobby') + name2.handleChange('Caroline') + + expect(form.state.values).toEqual({ + items: [ + { name: 'Alicia' }, + { name: 'Dan' }, + { name: 'Bobby' }, + { name: 'Caroline' }, + ], + }) + }) + + it('inserts the first item into an empty array', () => { + const form = new InternalFormApi({ + defaultValues: { items: [] as Array }, + }) + + form.insertFieldValue('items', 0, 'x') + + expect(form.state.values).toEqual({ items: ['x'] }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.insertFieldValue('name', 0, 'Bob') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('insertFieldValue')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a negative insertion index', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.insertFieldValue('items', -1, 'x') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for an insertion index beyond the array length', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.insertFieldValue('items', 3, 'x') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('inserts values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + + form.insertFieldValue('items', 1, 'x', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'x', 'b'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - clearFieldValues', () => { + it('clears all items, deactivates their fields, and keeps the array field usable', () => { + const form = new InternalFormApi({ + defaultValues: { items: [{ name: 'Alice' }, { name: 'Bob' }] }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + + form.clearFieldValues('items') + + expect(form.state.values).toEqual({ items: [] }) + expect(arrayField.value).toEqual([]) + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + + name0.handleChange('Ignored old row 0') + name1.handleChange('Ignored old row 1') + + expect(form.state.values).toEqual({ items: [] }) + + // Reuse index 0; its old field must not overwrite the new item. + arrayField.pushValue({ name: 'Carol' }) + const newItem0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const newName0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(newItem0.meta).toMatchObject(untouchedMeta) + expect(newName0.meta).toMatchObject(untouchedMeta) + newName0.handleChange('Caroline') + name0.handleChange('Ignored old row 0') + + expect(arrayField.value).toEqual([{ name: 'Caroline' }]) + expect(form.state.values).toEqual({ items: [{ name: 'Caroline' }] }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.clearFieldValues('name') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('clearFieldValues')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('clears values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + + form.clearFieldValues('items', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: [] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - removeFieldValue', () => { + it('removes middle, first, and last items while preserving the remaining order', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c', 'd', 'e', 'f'] }, + }) + + // Before: 0 1 2 3 4 5 + // After: 0 2 3 4 5 + form.removeFieldValue('items', 1) + + expect(form.state.values).toEqual({ items: ['a', 'c', 'd', 'e', 'f'] }) + + // Before: 0 2 3 4 5 + // After: 2 3 4 5 + form.removeFieldValue('items', 0) + + expect(form.state.values).toEqual({ items: ['c', 'd', 'e', 'f'] }) + + // Before: 2 3 4 5 + // After: 2 3 4 + form.removeFieldValue('items', 3) + + expect(form.state.values).toEqual({ items: ['c', 'd', 'e'] }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.removeFieldValue('name', 0) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('removeFieldValue')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a negative removal index', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.removeFieldValue('items', -1) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a removal index outside the array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.removeFieldValue('items', 2) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('deactivates the removed field and keeps surviving nested fields attached to their items', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + + // Before: 0 1 2 + // After: 0 2 + // Fields: name0 name2 + form.removeFieldValue('items', 1) + + expect(form.state.values).toEqual({ + items: [{ name: 'Alice' }, { name: 'Carol' }], + }) + expect(name0.name).toBe('items[0].name') + expect(name0.value).toBe('Alice') + expect(name2.name).toBe('items[1].name') + expect(name2.value).toBe('Carol') + + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alicia') + name2.handleChange('Caroline') + name1.handleChange('Ignored removed row') + + expect(form.state.values).toEqual({ + items: [{ name: 'Alicia' }, { name: 'Caroline' }], + }) + }) + + it('applies markAsBlurred only to the array when removing an item', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + + form.removeFieldValue('items', 1, { markAsBlurred: true }) + + expect(form.state.values).toEqual({ + items: [{ name: 'Alice' }, { name: 'Carol' }], + }) + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: true, + }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + }) + + it('removes values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + + form.removeFieldValue('items', 1, { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'c'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - replaceFieldValue', () => { + it('replaces items at their indices and keeps existing nested fields usable', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Carol' }], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item1 = form._getOrCreateFieldApi({ name: 'items[1]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + + // Before: 0 1 2 + // After: 0 x 2 + // Fields: name0 name1 name2 + form.replaceFieldValue('items', 1, { name: 'Dan' }) + + expect(form.state).toMatchObject({ + values: { + items: [{ name: 'Alice' }, { name: 'Dan' }, { name: 'Carol' }], + }, + isTouched: true, + isDirty: true, + isPristine: false, + }) + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + }) + + form.replaceFieldValue('items', 0, { name: 'Alicia' }) + + expect(form.state.values).toEqual({ + items: [{ name: 'Alicia' }, { name: 'Dan' }, { name: 'Carol' }], + }) + + form.replaceFieldValue('items', 2, { name: 'Caroline' }) + + expect(form.state.values).toEqual({ + items: [{ name: 'Alicia' }, { name: 'Dan' }, { name: 'Caroline' }], + }) + expect(name0.name).toBe('items[0].name') + expect(name0.value).toBe('Alicia') + expect(name1.name).toBe('items[1].name') + expect(name1.value).toBe('Dan') + expect(name2.name).toBe('items[2].name') + expect(name2.value).toBe('Caroline') + + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item1.meta).toMatchObject(untouchedMeta) + expect(name1.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alice edited') + name1.handleChange('Dan edited') + name2.handleChange('Carol edited') + + expect(form.state.values).toEqual({ + items: [ + { name: 'Alice edited' }, + { name: 'Dan edited' }, + { name: 'Carol edited' }, + ], + }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.replaceFieldValue('name', 0, 'Bob') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('replaceFieldValue')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a negative replacement index', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.replaceFieldValue('items', -1, 'x') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a replacement index outside the array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.replaceFieldValue('items', 2, 'x') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('warns and leaves values and interaction state unchanged for a fractional replacement index', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.replaceFieldValue('items', 0.5, 'x') + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('out of bounds')]), + ) + expect(form.state).toMatchObject({ + values: { items: ['a', 'b'] }, + isTouched: false, + isDirty: false, + }) + expect(field.meta).toMatchObject({ + isTouched: false, + isDirty: false, + }) + }) + + it('revalidates replaced nested values and preserves errors on other items', async () => { + const requiredName = ({ value }: { value: string }) => + value ? undefined : 'Name is required' + const form = new InternalFormApi({ + defaultValues: { items: [{ name: '' }, { name: '' }] }, + }) + form._getOrCreateFieldApi({ name: 'items' })._register() + const name0 = form._getOrCreateFieldApi({ + name: 'items[0].name', + validators: [{ triggers: ['change'], run: requiredName }], + }) + const name1 = form._getOrCreateFieldApi({ + name: 'items[1].name', + validators: [{ triggers: ['change'], run: requiredName }], + }) + name0._register() + name1._register() + + name0.handleChange('') + name1.handleChange('') + + await expect + .poll(() => name0.errors) + .toEqual([{ message: 'Name is required' }]) + await expect + .poll(() => name1.errors) + .toEqual([{ message: 'Name is required' }]) + + form.replaceFieldValue('items', 1, { name: 'Bob' }) + + expect(name1.value).toBe('Bob') + await expect.poll(() => name1.errors).toEqual([]) + expect(name0.errors).toEqual([{ message: 'Name is required' }]) + + form.replaceFieldValue('items', 1, { name: '' }) + + expect(name1.value).toBe('') + await expect + .poll(() => name1.errors) + .toEqual([{ message: 'Name is required' }]) + expect(name0.errors).toEqual([{ message: 'Name is required' }]) + }) + + it('reports and clears array validation errors when replacing an item', async () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b'] }, + }) + const field = form._getOrCreateFieldApi({ + name: 'items', + validators: [ + { + triggers: ['change'], + run: ({ value }: { value: Array }) => + value.includes('blocked') + ? 'Blocked items are not allowed' + : undefined, + }, + ], + }) + field._register() + + form.replaceFieldValue('items', 0, 'blocked') + + expect(field.value).toEqual(['blocked', 'b']) + await expect + .poll(() => field.errors) + .toEqual([{ message: 'Blocked items are not allowed' }]) + + form.replaceFieldValue('items', 0, 'a') + + expect(field.value).toEqual(['a', 'b']) + await expect.poll(() => field.errors).toEqual([]) + }) + + it('replaces values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) + + form.replaceFieldValue('items', 1, 'x', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'x'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) + +describe('InternalFormApi - filterFieldValues', () => { + it('filters objects and keeps only surviving nested fields usable', () => { + const form = new InternalFormApi({ + defaultValues: { + items: [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + { name: 'Carol', age: 35 }, + { name: 'Dan', age: 20 }, + ], + }, + }) + const arrayField = form._getOrCreateFieldApi({ name: 'items' }) + const item0 = form._getOrCreateFieldApi({ name: 'items[0]' }) + const item2 = form._getOrCreateFieldApi({ name: 'items[2]' }) + const name0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) + const name1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) + const name2 = form._getOrCreateFieldApi({ name: 'items[2].name' }) + const name3 = form._getOrCreateFieldApi({ name: 'items[3].name' }) + + // Before: 0 1 2 3 + // After: 0 2 + // Fields: name0 name2 + form.filterFieldValues( + 'items', + (person: { age: number }) => person.age > 28, + ) + + expect(form.state.values).toEqual({ + items: [ + { name: 'Alice', age: 30 }, + { name: 'Carol', age: 35 }, + ], + }) + expect(name0.name).toBe('items[0].name') + expect(name0.value).toBe('Alice') + expect(name2.name).toBe('items[1].name') + expect(name2.value).toBe('Carol') + + expect(arrayField.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + }) + const untouchedMeta = { isTouched: false, isDirty: false, isBlurred: false } + expect(item0.meta).toMatchObject(untouchedMeta) + expect(name0.meta).toMatchObject(untouchedMeta) + expect(item2.meta).toMatchObject(untouchedMeta) + expect(name2.meta).toMatchObject(untouchedMeta) + + name0.handleChange('Alicia') + name2.handleChange('Caroline') + name1.handleChange('Ignored removed row 1') + name3.handleChange('Ignored removed row 3') + + expect(form.state.values).toEqual({ + items: [ + { name: 'Alicia', age: 30 }, + { name: 'Caroline', age: 35 }, + ], + }) + }) + + it('passes original item indices to the filter predicate', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c', 'd'] }, + }) + + // Before: 0 1 2 3 + // After: 0 2 + form.filterFieldValues('items', (_, index) => index % 2 === 0) + + expect(form.state.values).toEqual({ items: ['a', 'c'] }) + }) + + it('passes the original array to the filter predicate', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + + form.filterFieldValues( + 'items', + // is last + (_, index, array) => index === array.length - 1, + ) + + expect(form.state.values).toEqual({ items: ['c'] }) + }) + + it('uses the supplied thisArg as the filter predicate context', () => { + // Yeah, this one is mostly just to be the same implementation as + // a normal array.filter . Who would actually use this? no idea. + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const thisArg = { firstKeptIndex: 1 } + + form.filterFieldValues( + 'items', + function (this: { firstKeptIndex: number }, _, index) { + return index >= this.firstKeptIndex + }, + { thisArg }, + ) + + expect(form.state.values).toEqual({ items: ['b', 'c'] }) + }) + + it('removes every item when the predicate rejects all items', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + + form.filterFieldValues('items', () => false) + + expect(form.state.values).toEqual({ items: [] }) + }) + + it('keeps every item and marks the field and form touched and dirty', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.filterFieldValues('items', () => true) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'b', 'c'] }, + isTouched: true, + isDirty: true, + isPristine: false, + }) + expect(field.meta).toMatchObject({ + isTouched: true, + isDirty: true, + isPristine: false, + }) + }) + + it('leaves an empty array empty', () => { + const form = new InternalFormApi({ + defaultValues: { items: [] as Array }, + }) + + form.filterFieldValues('items', () => true) + + expect(form.state.values).toEqual({ items: [] }) + }) + + it('warns and preserves values when the field is not an array', ({ + onTestFinished, + }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + onTestFinished(() => warn.mockRestore()) + const form = new InternalFormApi({ defaultValues: { name: 'Alice' } }) + + form.filterFieldValues('name', () => true) + + expect(warn.mock.calls).toContainEqual( + expect.arrayContaining([expect.stringContaining('filterFieldValues')]), + ) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + }) + }) + + it('filters values without marking the form touched or dirty when metadata flags are false', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + + form.filterFieldValues('items', (value) => value !== 'b', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + expect(form.state).toMatchObject({ + values: { items: ['a', 'c'] }, + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) +}) diff --git a/packages/form-core/tests/FormApi/field-array-methods.spec.ts b/packages/form-core/tests/FormApi/field-array-methods.spec.ts deleted file mode 100644 index ea36165186..0000000000 --- a/packages/form-core/tests/FormApi/field-array-methods.spec.ts +++ /dev/null @@ -1,640 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('form - field array methods', () => { - const withoutFieldMeta = { - markAsDirty: false, - markAsTouched: false, - markAsBlurred: false, - } - - describe('swapFieldValues', () => { - it('swaps two elements in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.swapFieldValues('items', 0, 2) - expect(form.getFieldValue('items')).toEqual(['c', 'b', 'a']) - }) - - it('does nothing when indexA === indexB', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.swapFieldValues('items', 1, 1) - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.swapFieldValues('name', 0, 1) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('updates field segments after swap', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - form.swapFieldValues('items', 0, 1) - expect(field0._segment).toBe(1) - expect(field1._segment).toBe(0) - }) - - it('updates values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - form.swapFieldValues('items', 0, 1, withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual(['b', 'a']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('moveFieldValue', () => { - it('moves an element forward in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - form.moveFieldValue('items', 0, 2) - expect(form.getFieldValue('items')).toEqual(['b', 'c', 'a', 'd']) - }) - - it('moves an element backward in an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - form.moveFieldValue('items', 2, 0) - expect(form.getFieldValue('items')).toEqual(['c', 'a', 'b', 'd']) - }) - - it('does nothing when fromIndex === toIndex', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.moveFieldValue('items', 1, 1) - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - - form.moveFieldValue('name', 0, 1) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('warns when an index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - form.moveFieldValue('items', 0, 2) - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('updates field segments after move', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - const field3 = form._getOrCreateFieldApi({ name: 'items[3]' }) - - form.moveFieldValue('items', 0, 2) - - expect(field0._segment).toBe(2) - expect(field1._segment).toBe(0) - expect(field2._segment).toBe(1) - expect(field3._segment).toBe(3) - expect(form._tryGetFieldApi('items[0]')).toBe(field1) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - expect(form._tryGetFieldApi('items[2]')).toBe(field0) - expect(form._tryGetFieldApi('items[3]')).toBe(field3) - }) - - it('moves values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - - form.moveFieldValue('items', 0, 2, withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual(['b', 'c', 'a']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('pushFieldValue', () => { - it('appends a value to an array field', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.pushFieldValue('items', 'c') - expect(form.getFieldValue('items')).toEqual(['a', 'b', 'c']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - const field = form._getOrCreateFieldApi({ name: 'name' }) - form.pushFieldValue('name', 'x', { fieldApiOverride: field }) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - }) - - describe('insertFieldValue', () => { - it('inserts a value at the specified index', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.insertFieldValue('items', 1, 'x') - expect(form.getFieldValue('items')).toEqual(['a', 'x', 'b']) - form.insertFieldValue('items', 0, 'y') - expect(form.getFieldValue('items')).toEqual(['y', 'a', 'x', 'b']) - form.insertFieldValue('items', 3, 'z') - expect(form.getFieldValue('items')).toEqual(['y', 'a', 'x', 'z', 'b']) - }) - - it('inserts a value in an empty array', () => { - const form = new InternalFormApi({ - defaultValues: { items: [] as Array }, - }) - form.insertFieldValue('items', 0, 'x') - expect(form.getFieldValue('items')).toEqual(['x']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.insertFieldValue('name', 0, 'x') - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('warns when index is negative', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.insertFieldValue('items', -1, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('warns when index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.insertFieldValue('items', 3, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('shifts child field segments after insert', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - form.insertFieldValue('items', 1, 'x') - expect(field0._segment).toBe(0) - expect(field1._segment).toBe(2) - expect(field2._segment).toBe(3) - - expect(form._getOrCreateFieldApi({ name: 'items[0]' })).toBe(field0) - expect(form._getOrCreateFieldApi({ name: 'items[1]' })).not.toBe(field1) - expect(form._getOrCreateFieldApi({ name: 'items[2]' })).toBe(field1) - expect(form._getOrCreateFieldApi({ name: 'items[3]' })).toBe(field2) - }) - - it("updates the array field's meta", () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - form.insertFieldValue('items', 1, 'x') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('inserts without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - form.insertFieldValue('items', 1, 'x', withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual(['a', 'x', 'b']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('clearFieldValues', () => { - it('empties an array field', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.clearFieldValues('items') - expect(form.getFieldValue('items')).toEqual([]) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.clearFieldValues('name') - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('kills child fields', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const arrayField = form._getOrCreateFieldApi({ name: 'items' }) - form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - - form.clearFieldValues('items') - - expect(form._tryGetFieldApi('items[0]')).toBeNull() - expect(form._tryGetFieldApi('items[1]')).toBeNull() - expect(arrayField._children).toEqual([]) - }) - - it('clears values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - form.clearFieldValues('items', withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual([]) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('removeFieldValue', () => { - it('removes an element at the specified index', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd', 'e', 'f'] }, - }) - form.removeFieldValue('items', 1) - expect(form.getFieldValue('items')).toEqual(['a', 'c', 'd', 'e', 'f']) - form.removeFieldValue('items', 0) - expect(form.getFieldValue('items')).toEqual(['c', 'd', 'e', 'f']) - form.removeFieldValue('items', 3) - expect(form.getFieldValue('items')).toEqual(['c', 'd', 'e']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.removeFieldValue('name', 0) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('warns when index is negative', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.removeFieldValue('items', -1) - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('warns when index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.removeFieldValue('items', 2) - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('kills the removed child field and shifts remaining children', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - - form.removeFieldValue('items', 1) - - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - expect(form._tryGetFieldApi('items[2]')).toBeNull() - expect(field0._segment).toBe(0) - expect(field2._segment).toBe(1) - }) - - it('removes values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - - form.removeFieldValue('items', 1, withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual(['a', 'c']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('replaceFieldValue', () => { - it('replaces the element at the specified index', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.replaceFieldValue('items', 1, 'x') - expect(form.getFieldValue('items')).toEqual(['a', 'x', 'c']) - form.replaceFieldValue('items', 0, 'y') - expect(form.getFieldValue('items')).toEqual(['y', 'x', 'c']) - form.replaceFieldValue('items', 2, 'z') - expect(form.getFieldValue('items')).toEqual(['y', 'x', 'z']) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.replaceFieldValue('name', 0, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('name')).toBe('') - warn.mockRestore() - }) - - it('warns when index is negative', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.replaceFieldValue('items', -1, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('warns when index is out of bounds', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - form.replaceFieldValue('items', 2, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - warn.mockRestore() - }) - - it('warns when index is not an integer', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - form.replaceFieldValue('items', 0.5, 'x') - expect(warn).toHaveBeenCalled() - expect(form.getFieldValue('items')).toEqual(['a', 'b']) - expect(Object.keys(form.getFieldValue('items'))).toEqual(['0', '1']) - expect(field.meta.isDirty).toBe(false) - warn.mockRestore() - }) - - it("revalidates the replaced element's child fields", async () => { - const nameValidator = vi.fn(({ value }: { value: string }) => - value ? undefined : 'required', - ) - const form = new InternalFormApi({ - defaultValues: { items: [{ name: 'a' }, { name: '' }] }, - }) - form._getOrCreateFieldApi({ name: 'items' })._register() - const otherField = form._getOrCreateFieldApi({ - name: 'items[0].name', - validators: [{ run: nameValidator, triggers: ['change'] }], - }) - const nameField = form._getOrCreateFieldApi({ - name: 'items[1].name', - validators: [{ run: nameValidator, triggers: ['change'] }], - }) - otherField._register() - nameField._register() - - nameField.handleChange('') - await vi.waitFor(() => - expect(nameField.errors).toEqual([{ message: 'required' }]), - ) - nameValidator.mockClear() - - form.replaceFieldValue('items', 1, { name: 'x' }) - - expect(form._tryGetFieldApi('items[1].name')).toBe(nameField) - await vi.waitFor(() => expect(nameField.errors).toEqual([])) - expect(nameValidator).toHaveBeenCalledTimes(1) - expect(nameValidator).toHaveBeenCalledWith( - expect.objectContaining({ event: 'change', value: 'x' }), - ) - }) - - it("updates the array field's meta", () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - form.replaceFieldValue('items', 1, 'x') - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('keeps child fields at their index', () => { - const form = new InternalFormApi({ - defaultValues: { items: [{ name: 'a' }, { name: 'b' }] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0].name' }) - const field1 = form._getOrCreateFieldApi({ name: 'items[1].name' }) - - form.replaceFieldValue('items', 1, { name: 'x' }) - - expect(form._tryGetFieldApi('items[0].name')).toBe(field0) - expect(form._tryGetFieldApi('items[1].name')).toBe(field1) - expect(field0.value).toBe('a') - expect(field1.value).toBe('x') - }) - - it('runs change validation on the array field', () => { - const validator = vi.fn(() => undefined) - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b'] }, - }) - const field = form._getOrCreateFieldApi({ - name: 'items', - validators: [{ run: validator, triggers: ['change'] }], - }) - const unregister = field._register() - - form.replaceFieldValue('items', 0, 'x') - expect(validator).toHaveBeenCalledWith( - expect.objectContaining({ event: 'change', value: ['x', 'b'] }), - ) - - unregister() - }) - - it('replaces values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ defaultValues: { items: ['a', 'b'] } }) - - form.replaceFieldValue('items', 1, 'x', withoutFieldMeta) - - expect(form.getFieldValue('items')).toEqual(['a', 'x']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) - - describe('filterFieldValues', () => { - it('filters array elements based on predicate', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - form.filterFieldValues('items', (value) => value !== 'b' && value !== 'd') - expect(form.getFieldValue('items')).toEqual(['a', 'c']) - }) - - it('filters with index-aware predicate', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - form.filterFieldValues('items', (_, index) => index % 2 === 0) - expect(form.getFieldValue('items')).toEqual(['a', 'c']) - }) - - it('filters with array-aware predicate', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.filterFieldValues('items', (_, index, array) => { - return index === array.length - 1 - }) - expect(form.getFieldValue('items')).toEqual(['c']) - }) - - it('uses thisArg when provided', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const thisObj = { threshold: 1 } - form.filterFieldValues( - 'items', - function (_, index) { - // @ts-expect-error - Who tf uses thisArg anyways - return index >= this.threshold - }, - { thisArg: thisObj }, - ) - expect(form.getFieldValue('items')).toEqual(['b', 'c']) - }) - - it('filters out all elements', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.filterFieldValues('items', () => false) - expect(form.getFieldValue('items')).toEqual([]) - }) - - it('keeps all elements when predicate always returns true', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - form.filterFieldValues('items', () => true) - expect(form.getFieldValue('items')).toEqual(['a', 'b', 'c']) - }) - - it('filters an empty array', () => { - const form = new InternalFormApi({ - defaultValues: { items: [] as Array }, - }) - form.filterFieldValues('items', () => true) - expect(form.getFieldValue('items')).toEqual([]) - }) - - it('warns when called on a non-array field', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const form = new InternalFormApi({ defaultValues: { name: '' } }) - form.filterFieldValues('name', () => true) - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - - it('reorganizes child field segments after filtering', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c', 'd'] }, - }) - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - form._getOrCreateFieldApi({ name: 'items[3]' }) - - // Keep only indices 0 and 2 - form.filterFieldValues('items', (_, index) => index === 0 || index === 2) - - expect(field0._segment).toBe(0) - expect(field2._segment).toBe(1) - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - expect(form._tryGetFieldApi('items[2]')).toBeNull() - expect(form._tryGetFieldApi('items[3]')).toBeNull() - }) - - it('kills removed child fields', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - - const field0 = form._getOrCreateFieldApi({ name: 'items[0]' }) - form._getOrCreateFieldApi({ name: 'items[1]' }) - const field2 = form._getOrCreateFieldApi({ name: 'items[2]' }) - - form.filterFieldValues('items', (_, index) => index !== 1) - - expect(form._tryGetFieldApi('items[0]')).toBe(field0) - expect(form._tryGetFieldApi('items[1]')).toBe(field2) - }) - - it('updates array field meta when filtered', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - const field = form._getOrCreateFieldApi({ name: 'items' }) - form.filterFieldValues('items', () => true) - expect(field.meta.isDirty).toBe(true) - expect(field.meta.isTouched).toBe(true) - }) - - it('does not set value when no elements are filtered out', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - - const originalArray = form.getFieldValue('items') - form.filterFieldValues('items', () => true) - expect(form.getFieldValue('items')).toBe(originalArray) - }) - - it('handles complex objects in array', () => { - const form = new InternalFormApi({ - defaultValues: { - items: [ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 25 }, - { name: 'Charlie', age: 35 }, - ], - }, - }) - form.filterFieldValues('items', (person: any) => person.age > 28) - expect(form.getFieldValue('items')).toEqual([ - { name: 'Alice', age: 30 }, - { name: 'Charlie', age: 35 }, - ]) - }) - - it('filters values without creating a field node when meta updates are disabled', () => { - const form = new InternalFormApi({ - defaultValues: { items: ['a', 'b', 'c'] }, - }) - - form.filterFieldValues( - 'items', - (value) => value !== 'b', - withoutFieldMeta, - ) - - expect(form.getFieldValue('items')).toEqual(['a', 'c']) - expect(form._tryGetFieldApi('items')).toBeNull() - }) - }) -}) diff --git a/packages/form-core/tests/FormApi/field-meta.spec.ts b/packages/form-core/tests/FormApi/field-meta.spec.ts deleted file mode 100644 index 29d846562e..0000000000 --- a/packages/form-core/tests/FormApi/field-meta.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { InternalFormApi } from '../../src/FormApi/FormApi.lib' - -describe('form - field meta', () => { - describe('getFieldMeta', () => { - it('should retrieve field meta', () => { - const form = new InternalFormApi({ defaultValues: { name: 'hi' } }) - - expect(form.getFieldMeta('name')).toEqual(undefined) - - form._getOrCreateFieldApi({ name: 'name' }) - form.setFieldValue('name', 'bye') - expect(form.getFieldMeta('name')).toBeDefined() - }) - }) -}) From 1c67c679d7a788650d2af14100bd2e268cc594d2 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:05:37 +0200 Subject: [PATCH 18/43] fix: satisfy knip and fix tsconfig --- packages/form-core/src/FieldApi/fieldTree.lib.ts | 2 +- tsconfig.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/FieldApi/fieldTree.lib.ts b/packages/form-core/src/FieldApi/fieldTree.lib.ts index 98faeb593c..7cc558f2f5 100644 --- a/packages/form-core/src/FieldApi/fieldTree.lib.ts +++ b/packages/form-core/src/FieldApi/fieldTree.lib.ts @@ -450,7 +450,7 @@ export function killField( reattachFormGroups() } -export function canPruneField(field: AnyInternalFieldApi): boolean { +function canPruneField(field: AnyInternalFieldApi): boolean { if (field._isKilled) return false if (field._refCount > 0) return false diff --git a/tsconfig.json b/tsconfig.json index c495877a1f..82cbd80270 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["DOM", "DOM.Iterable", "ES2022", "ES2024.Promise"], + "lib": ["DOM", "DOM.Iterable", "ES2022", "ESNext.Promise"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, From 0860a9d9c5c6537d0965dde5825ab3b1ffb37deb Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:23:03 +0200 Subject: [PATCH 19/43] fix: fix tsconfig for real this time --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 82cbd80270..338bc4f6ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["DOM", "DOM.Iterable", "ES2022", "ESNext.Promise"], + "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, From 026606e3e6d0284a0e7141f0214546994f18167a Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:56:45 +0200 Subject: [PATCH 20/43] chore: patch core tests to get testing started --- .../form-core/src/FieldApi/FieldApi.lib.ts | 2 +- packages/form-core/src/FormApi/FormApi.lib.ts | 6 +- .../form-core/src/FormApi/handleSubmit.lib.ts | 4 +- .../src/FormGroupApi/FormGroupApi.lib.ts | 16 +- packages/form-core/src/createErrorMap.lib.ts | 26 -- packages/form-core/src/ssr.lib.ts | 12 +- packages/form-core/src/validation.public.ts | 47 +-- .../form-core/src/validation/execution.lib.ts | 21 +- .../form-core/src/validation/mount.lib.ts | 57 +-- .../form-core/src/validation/pipeline.lib.ts | 6 +- .../form-core/tests/FieldApi/meta.spec.ts | 2 +- .../tests/FieldApi/validation.spec.ts | 6 +- .../tests/FormApi/FormApi.lib.spec.ts | 95 +++-- .../tests/FormApi/FormApi.public.test-d.ts | 17 +- .../FormApi/formOptions.public.test-d.ts | 9 +- .../tests/FormApi/handleSubmit.lib.spec.ts | 2 +- .../tests/FormApi/submission-handling.spec.ts | 54 +-- .../tests/FormApi/validation.spec.ts | 97 +++-- .../tests/FormGroupApi/FormGroupApi.spec.ts | 16 +- .../form-core/tests/ValidatorInstance.spec.ts | 12 +- .../form-core/tests/serverValidate.spec.ts | 27 +- .../form-core/tests/serverValidate.test-d.ts | 335 ++++-------------- .../form-core/tests/standardSchema.spec.ts | 4 +- .../tests/validation-output.test-d.ts | 113 +++--- .../form-core/tests/validation-output.test.ts | 89 +++-- .../tests/validation-pipeline.test.ts | 104 +++--- packages/form-core/tests/validation.test-d.ts | 250 ++++++------- .../fieldDebug/validatorsWithoutTriggers.ts | 2 +- .../validatorsWithoutTriggers.tsx | 5 +- .../tests/fieldDebugCases.test.ts | 8 +- .../tests/fieldDebugInfoPopover.test.tsx | 1 - packages/react-form-start/src/getFormData.ts | 2 +- 32 files changed, 643 insertions(+), 804 deletions(-) delete mode 100644 packages/form-core/src/createErrorMap.lib.ts diff --git a/packages/form-core/src/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index a87f0c161e..8d1ae20fdf 100644 --- a/packages/form-core/src/FieldApi/FieldApi.lib.ts +++ b/packages/form-core/src/FieldApi/FieldApi.lib.ts @@ -1146,7 +1146,7 @@ export class InternalFieldApi< /** * @private - * Runs validators marked with runOnMount on the first component mount. + * Runs validators with an enabled mount trigger on the first component mount. */ _runMountValidation(validators = this._validatorInstances): void { if (!validators || validators.length === 0) return diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 35cef0875a..167ac831a2 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -697,7 +697,11 @@ export class InternalFormApi implements FormApi< this._atoms.meta.isDirty.set(true) } - field?._notifyEvent(options, 'change') + if (field) { + field._notifyEvent(options, 'change') + } else if (options.causeValidation) { + void this.validate('change') + } this._notifyFormListener('change', field) } diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index 197509fe59..19092ef862 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -4,7 +4,7 @@ import { isValidationErrorMap, parseValidationResult, } from '../validation' -import { createErrorMapFactory } from '../createErrorMap.lib' +import { createErrorMap } from '../validation/createErrorMap.lib' import { parseIssuesFactory } from '../validation/parseIssues.lib' import type { InternalFormApi } from './FormApi.lib' import type { ValidationErrorInput } from '../validation.public' @@ -174,7 +174,7 @@ export async function runSubmissionProcess( validatorOutputs, value, createValidationError, - createErrorMap: withSubmitError(createErrorMapFactory(value)), + createErrorMap: withSubmitError(createErrorMap), parseIssues: withSubmitError(parseIssuesFactory(value)), } satisfies FormSubmitContext as any) diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index c30c24b567..bddc8249c4 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -18,7 +18,8 @@ import { deriveFromBaseFieldMeta, hasFieldMetaErrors, } from '../FieldApi/fieldState.lib' -import { createErrorMapFactory } from '../createErrorMap.lib' +import { createErrorMap } from '../validation/createErrorMap.lib' +import { parseIssuesFactory } from '../validation/parseIssues.lib' import { reconcileValidatorInstances } from '../ValidatorInstance.lib' import { resolveDefaultOptions } from '../defaultOptions.lib' import type { FormApi } from '../FormApi/FormApi.public' @@ -40,7 +41,7 @@ import type { FormGroupState, } from './FormGroupApi.public' import type { - ConfigurableValidationTrigger, + ClientValidationTrigger, FormGroupValidateResult, FormGroupValidators, ToFormGroupErrorTypes, @@ -484,7 +485,7 @@ export class InternalFormGroupApi< } async _runFieldValidations( - signal: ConfigurableValidationTrigger | 'submit', + signal: ClientValidationTrigger, ): Promise> { const fieldValidationPromises: Array< ReturnType @@ -546,7 +547,7 @@ export class InternalFormGroupApi< _clearEventErrors( field: AnyInternalFieldApi, sourceEvent: string, - event: ConfigurableValidationTrigger, + event: Exclude, ) { const groupField = this._groupField const validatorInstancesToClear: Array = [] @@ -647,7 +648,7 @@ export class InternalFormGroupApi< } _validate = async ( - signal: ConfigurableValidationTrigger | 'submit', + signal: ClientValidationTrigger, opts?: { triggerFieldApi?: AnyInternalFieldApi }, ): Promise> => { if (signal !== 'submit' && opts?.triggerFieldApi) { @@ -687,7 +688,8 @@ export class InternalFormGroupApi< triggerFieldApi: opts?.triggerFieldApi, signal: ctx.signal, value: this.value, - createErrorMap: createErrorMapFactory(this.value), + createErrorMap, + parseIssues: parseIssuesFactory(this.value), createOutput, }), onResult: (result) => { @@ -718,7 +720,7 @@ export class InternalFormGroupApi< } validate = async ( - signal: ConfigurableValidationTrigger | 'submit', + signal: ClientValidationTrigger, opts?: { triggerFieldApi?: AnyInternalFieldApi }, ): Promise>> => { return (await this._validate(signal, opts)).errors diff --git a/packages/form-core/src/createErrorMap.lib.ts b/packages/form-core/src/createErrorMap.lib.ts deleted file mode 100644 index 60ac153fb2..0000000000 --- a/packages/form-core/src/createErrorMap.lib.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { parseStandardSchemaIssues } from './standardSchema.lib' -import { isReadonlyArray } from './utils.lib' -import type { - CreateErrorMapFn, - ValidationErrorMap, -} from './validation/state.public' - -type TupleNarrower = (arg: any) => arg is ReadonlyArray - -/** - * Standard schema needs path parsing, so the error map function needs context of form values. - */ -export function createErrorMapFactory( - value: TFormData, -): CreateErrorMapFn { - const createErrorMap = ((initial) => { - if (isReadonlyArray(initial)) { - return parseStandardSchemaIssues(initial, value, 'form') - } - const map = initial ?? ({} as ValidationErrorMap) - map.fields ??= {} - return map - }) as CreateErrorMapFn - - return createErrorMap -} diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index f4259f4e55..6b8d6ce5bc 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -4,7 +4,8 @@ import { defaultInternalBaseFieldMeta } from './FieldApi/fieldState.lib' import { visitAllFormFields } from './FieldApi/fieldTraversal.lib' import { evaluate } from './utils.lib' import { runValidatorPipeline } from './validation' -import { createErrorMapFactory } from './createErrorMap.lib' +import { createErrorMap } from './validation/createErrorMap.lib' +import { parseIssuesFactory } from './validation/parseIssues.lib' import { devtools } from './devtoolsBridge.lib' import { reconcileValidatorInstances } from './ValidatorInstance.lib' import type { FormErrorMeta } from './FormApi/formState.lib' @@ -17,6 +18,12 @@ import type { } from './FormApi/formServerState.public' import type { FormOptions } from './FormApi/formOptions.public' +export const initialServerFormState: ServerFormState = { + values: undefined, + validationResults: [], + submissionAttempts: 0, +} + function createInitialFormErrorMeta(): FormErrorMeta { return { validationSourceErrors: null, @@ -152,7 +159,8 @@ export async function validateServerValues< signal: ctx.signal, formApi: undefined, value: values, - createErrorMap: createErrorMapFactory(values), + createErrorMap, + parseIssues: parseIssuesFactory(values), createOutput, }), scope: 'form', diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/validation.public.ts index b175be78bd..fa1b25673d 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/validation.public.ts @@ -10,6 +10,7 @@ import type { OneOrMany, ReadonlyOneOrMany } from './types.public' import type { CreateErrorMapFn, CreateOutputFn, + ParseIssuesFn, ScopedErrorTypes, ValidationErrorMap, ValidationIssue, @@ -67,13 +68,6 @@ export interface Validator< */ skipOnSubmit?: boolean | ValidationPredicateFn - /** - * Whether this validator runs when the form is constructed or when its field - * or form group first mounts. - * - * @default false - */ - runOnMount?: boolean /** * The delay in milliseconds before validation runs for change or blur events. * @@ -86,10 +80,10 @@ export interface Validator< triggerDebounceMs?: number | ValidationDebounceFn /** - * Change and blur events that can run this validator, optionally with conditions. + * Mount, change, and blur events that can run this validator, optionally with conditions. * - * An empty array disables change and blur validation. Configure submission - * separately with `skipOnSubmit` and initial validation with `runOnMount`. + * An empty array disables mount, change, and blur validation. Configure + * submission separately with `skipOnSubmit`. */ triggers: Array< ValidationTriggerOption @@ -264,8 +258,9 @@ export type ServerValidationTrigger = 'server' /** Events reported to client-side validation callbacks. */ export type ClientValidationTrigger = ValidationTrigger /** Events configured through `triggers`. Submission is controlled by `skipOnSubmit`. */ -export type ConfigurableValidationTrigger = Exclude -/** Change and blur events accepted by shared validator configurations. */ +export type ConfigurableValidationTrigger = + Exclude | 'mount' +/** Mount, change, and blur events accepted by shared validator configurations. */ export type ValidatorTrigger = ConfigurableValidationTrigger /** The form, form group, or field whose value a validator checks. */ export type ValidatorScope = 'form' | 'group' | 'field' @@ -444,7 +439,11 @@ export interface ValidationTriggerConfig< * * @default true */ - when?: boolean | ValidationPredicateFn + when?: + | boolean + | (TTrigger extends 'mount' + ? never + : ValidationPredicateFn) } /** @@ -460,7 +459,9 @@ export type ValidationTriggerOption< TValue, TTrigger extends ValidatorTrigger = ValidatorTrigger, TScope extends ValidatorScope = ValidatorScope, -> = TTrigger | ValidationTriggerConfig +> = TTrigger extends ValidatorTrigger + ? TTrigger | ValidationTriggerConfig + : never /** * A change or blur trigger, optionally enabled by a condition. @@ -512,7 +513,7 @@ export type ValidationErrorInput = ReadonlyOneOrMany */ export interface FormValidatorContext { /** The event that caused this validation run. */ - event: ValidationTrigger | ServerValidationTrigger + event: ValidationTrigger | ServerValidationTrigger | 'mount' /** Aborted when this run is cancelled. Pass it to asynchronous work such as `fetch`. */ signal: AbortSignal /** The owning form on the client. `undefined` during server validation. */ @@ -521,8 +522,10 @@ export interface FormValidatorContext { triggerFieldApi?: AnyFieldApi /** The complete form values being validated. */ value: TFormData - /** Builds an error map or routes Standard Schema issues using the form values. */ + /** Builds a mutable error map from optional form and field errors. */ createErrorMap: CreateErrorMapFn + /** Converts Standard Schema issues into a mutable error map using the form values. */ + parseIssues: ParseIssuesFn /** Creates a successful result to return data in this validator's `validatorOutputs` entry. */ createOutput: CreateOutputFn } @@ -543,8 +546,10 @@ export interface ServerFormValidatorContext { triggerFieldApi?: AnyFieldApi /** The complete form values passed to server validation. */ value: TFormData - /** Builds an error map or routes Standard Schema issues using the server values. */ + /** Builds a mutable error map from optional form and field errors. */ createErrorMap: CreateErrorMapFn + /** Converts Standard Schema issues into a mutable error map using the server values. */ + parseIssues: ParseIssuesFn /** Creates a successful result to return data in this validator's server `validatorOutputs` entry. */ createOutput: CreateOutputFn } @@ -578,7 +583,7 @@ export type ValidatorFn = ( */ export interface FormGroupValidatorContext { /** The event that caused this validation run. */ - event: ClientValidationTrigger + event: ClientValidationTrigger | 'mount' /** Aborted when this run is cancelled. Pass it to asynchronous work such as `fetch`. */ signal: AbortSignal /** The owning form, including values outside this group. */ @@ -589,8 +594,10 @@ export interface FormGroupValidatorContext { triggerFieldApi?: AnyFieldApi /** The group's current value, without the surrounding form values. */ value: TGroupValue - /** Builds an error map or routes Standard Schema issues at group-relative field paths. */ + /** Builds a mutable error map from optional group errors and errors at group-relative field paths. */ createErrorMap: CreateErrorMapFn + /** Converts Standard Schema issues into a mutable error map using the group's values and field paths. */ + parseIssues: ParseIssuesFn /** Creates a successful result to return data in this validator's `validatorOutputs` entry. */ createOutput: CreateOutputFn } @@ -655,7 +662,7 @@ export interface FieldValidatorContext< in out TFormData, > { /** The event that caused this validation run. */ - event: ClientValidationTrigger + event: ClientValidationTrigger | 'mount' /** Aborted when this run is cancelled. Pass it to asynchronous work such as `fetch`. */ signal: AbortSignal /** The owning form. Read other field values here for cross-field validation. */ diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index da9297967b..c1f26f1a96 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -41,7 +41,7 @@ type FieldValidateContext = Omit< > & { scope: 'field' } type FormGroupValidateContext = Omit< FormGroupValidatorContext, - 'value' | 'createErrorMap' | 'createOutput' + 'value' | 'createErrorMap' | 'parseIssues' | 'createOutput' > & { scope: 'group' } export type FormInputContext = Omit type ServerFormInputContext = Omit @@ -99,19 +99,6 @@ export function isFieldValidateContext( return ctx.scope === 'field' } -/** Checks whether a configured trigger is the server-only trigger. */ -function isServerTrigger( - trigger: ValidationTriggerOption | 'server', -): boolean { - return trigger === 'server' -} - -/** Checks whether a validator participates in server validation. */ -function hasServerTrigger(validator: AnyPipelineValidator): boolean { - if (!validator.triggers) return false - return validator.triggers.some(isServerTrigger) -} - /** * Creates the scope-specific context passed to predicate and debounce callbacks. * @@ -220,7 +207,11 @@ export function shouldRunValidator( context: InputContext, ): boolean { if (isServerContext(context)) { - return hasServerTrigger(validator) + return ( + validator.triggers?.some((trigger) => + isValidationTriggerEnabled(trigger, context), + ) ?? false + ) } const { skipOnSubmit = false } = validator diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index 35eedc0d51..da03b0ce60 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -1,11 +1,13 @@ import { createOutput } from '../validationOutput.lib' -import { createErrorMapFactory } from '../createErrorMap.lib' import { isStandardSchema, parseStandardSchema } from '../standardSchema.lib' import { isPromiseLike } from '../utils.lib' +import { createErrorMap } from './createErrorMap.lib' +import { parseIssuesFactory } from './parseIssues.lib' import { isErrorResult } from './errors.lib' import { createValidatorAbortContext, normalizeValidatorResult, + shouldRunValidator, } from './execution.lib' import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' import type { @@ -20,6 +22,7 @@ import type { } from '../validation.public' import type { AnyValidatorContext, + InputContext, ValidateResult, ValidatorExecutionResult, } from './execution.lib' @@ -39,6 +42,7 @@ export interface FormMountValidatorPipelineResult { interface MountValidatorPipelineArgs { pipeline: ReadonlyArray + context: InputContext getContext: (signal: AbortSignal) => AnyValidatorContext scope: 'field' | 'form' onResult?: (result: PipelineResult) => void @@ -143,8 +147,7 @@ function executeMountValidator( /** * Continues mount validation sequentially after the first asynchronous result. * - * Later validators still honor `runOnMount` and `bailIfInvalid` in configured - * order. + * Eligible validators still honor `bailIfInvalid` in configured order. */ async function continueMountValidationFromAsyncResult< TResult extends ValidateResult, @@ -173,7 +176,6 @@ async function continueMountValidationFromAsyncResult< for (let i = startIndex + 1; i < pipeline.length; i++) { const validatorInstance = pipeline[i]! const validator = validatorInstance.definition - if (validator.runOnMount !== true) continue if (validator.bailIfInvalid && hasFailed) break @@ -204,21 +206,16 @@ async function continueMountValidationFromAsyncResult< */ function runMountValidatorPipeline({ pipeline, + context, getContext, scope, onResult, }: MountValidatorPipelineArgs): FormMountValidatorPipelineResult { - if (pipeline.length === 0) - return { - didRun: false, - asyncPromise: null, - } - - if ( - !pipeline.some( - (validatorInstance) => validatorInstance.definition.runOnMount === true, - ) + const mountPipeline = pipeline.filter((validatorInstance) => + shouldRunValidator(validatorInstance.definition, context), ) + + if (mountPipeline.length === 0) return { didRun: false, asyncPromise: null, @@ -226,10 +223,9 @@ function runMountValidatorPipeline({ let hasFailed = false - for (let i = 0; i < pipeline.length; i++) { - const validatorInstance = pipeline[i]! + for (let i = 0; i < mountPipeline.length; i++) { + const validatorInstance = mountPipeline[i]! const validator = validatorInstance.definition - if (validator.runOnMount !== true) continue if (validator.bailIfInvalid && hasFailed) { return { @@ -248,7 +244,7 @@ function runMountValidatorPipeline({ return { didRun: true, asyncPromise: continueMountValidationFromAsyncResult( - pipeline, + mountPipeline, getContext, scope, i, @@ -280,12 +276,14 @@ export function runFormMountValidatorPipeline({ }: FormMountValidatorPipelineArgs): FormMountValidatorPipelineResult { return runMountValidatorPipeline>({ pipeline, + context: { scope: 'form', event: 'mount', formApi }, getContext: (signal) => ({ - event: 'mount' as never, + event: 'mount', signal, formApi, value: formApi.state.values, - createErrorMap: createErrorMapFactory(formApi.state.values), + createErrorMap, + parseIssues: parseIssuesFactory(formApi.state.values), createOutput, }), scope: 'form', @@ -307,8 +305,14 @@ export function runFieldMountValidatorPipeline({ }: FieldMountValidatorPipelineArgs): FormMountValidatorPipelineResult { return runMountValidatorPipeline({ pipeline, + context: { + scope: 'field', + event: 'mount', + formApi: fieldApi.form, + fieldApi, + }, getContext: (signal) => ({ - event: 'mount' as never, + event: 'mount', signal, formApi: fieldApi.form as never, fieldApi: fieldApi as never, @@ -335,14 +339,21 @@ export function runGroupMountValidatorPipeline({ }: GroupMountValidatorPipelineArgs): FormMountValidatorPipelineResult { return runMountValidatorPipeline>({ pipeline, + context: { + scope: 'group', + event: 'mount', + formApi: groupApi.form, + groupApi, + }, getContext: (signal) => ({ - event: 'mount' as never, + event: 'mount', signal, formApi: groupApi.form as never, groupApi: groupApi as never, triggerFieldApi: undefined, value: groupApi.value, - createErrorMap: createErrorMapFactory(groupApi.value), + createErrorMap, + parseIssues: parseIssuesFactory(groupApi.value), createOutput, }), scope: 'form', diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts index da81fd4e31..a7c1d4d21b 100644 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -1,6 +1,7 @@ import { createOutput } from '../validationOutput.lib' -import { createErrorMapFactory } from '../createErrorMap.lib' import { isNotNil } from '../utils.lib' +import { createErrorMap } from './createErrorMap.lib' +import { parseIssuesFactory } from './parseIssues.lib' import { isErrorResult } from './errors.lib' import { ABORTED_CALL, @@ -254,7 +255,8 @@ export function runFormValidatorPipeline({ formApi: ctx.formApi, signal: ctx.signal, value: ctx.formApi.state.values, - createErrorMap: createErrorMapFactory(ctx.formApi.state.values), + createErrorMap, + parseIssues: parseIssuesFactory(ctx.formApi.state.values), createOutput, } diff --git a/packages/form-core/tests/FieldApi/meta.spec.ts b/packages/form-core/tests/FieldApi/meta.spec.ts index 23e4369673..462af691d9 100644 --- a/packages/form-core/tests/FieldApi/meta.spec.ts +++ b/packages/form-core/tests/FieldApi/meta.spec.ts @@ -4,7 +4,7 @@ import { InternalFormGroupApi } from '../../src/FormGroupApi/FormGroupApi.lib' import { defaultFieldMeta } from '../../src/FieldApi/fieldState.lib' import { reconcileValidatorInstances } from '../../src/ValidatorInstance.lib' import type { AnyInternalFieldApi } from '../../src/FieldApi/FieldApi.lib' -import type { ValidationIssue } from '../../src/validation.public' +import type { ValidationIssue } from '../../src' import type { ValidationSourceErrorMap } from '../../src/validation' function setFieldValidatorErrors( diff --git a/packages/form-core/tests/FieldApi/validation.spec.ts b/packages/form-core/tests/FieldApi/validation.spec.ts index 246c06e646..715dba0c28 100644 --- a/packages/form-core/tests/FieldApi/validation.spec.ts +++ b/packages/form-core/tests/FieldApi/validation.spec.ts @@ -12,8 +12,7 @@ describe('field - mount validators', () => { validators: [ { run: validator, - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -69,8 +68,7 @@ describe('field - mount validators', () => { validators: [ { run: () => result, - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index af5c60051b..f33f2bdfc8 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -102,7 +102,6 @@ describe('InternalFormApi - _update', () => { defaultValues: { name: '' }, validators: [ { run: () => null, triggers: [] }, - // @ts-expect-error We're testing this on purpose { run: () => null, triggers: [] }, ], }) @@ -549,32 +548,74 @@ describe('InternalFormApi - setFieldValue', () => { }) }) - // TODO: Form validation is skipped when disabled metadata updates prevent field creation. - it.fails( - 'validates form changes before field creation when all metadata flags are false', - async () => { - const form = new InternalFormApi({ - defaultValues: { name: 'Alice' }, - validators: [ - { - triggers: ['change'], - run: ({ value }) => - value.name.length < 3 ? 'Name is too short' : undefined, - }, - ], - }) - - form.setFieldValue('name', 'Al', { - markAsTouched: false, - markAsDirty: false, - markAsBlurred: false, - }) - - await expect - .poll(() => form.state.errors) - .toEqual([{ message: 'Name is too short' }]) - }, - ) + it('validates form changes before field creation when all metadata flags are false', async () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice' }, + validators: [ + { + triggers: ['change'], + run: ({ value }) => + value.name.length < 3 ? 'Name is too short' : undefined, + }, + ], + }) + + form.setFieldValue('name', 'Al', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + await expect + .poll(() => form.state.errors) + .toEqual([{ message: 'Name is too short' }]) + expect(form.state).toMatchObject({ + values: { name: 'Al' }, + isTouched: false, + isDirty: false, + isPristine: true, + isValid: false, + }) + + form.setFieldValue('name', 'Alice', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) + + await expect.poll(() => form.state.errors).toEqual([]) + expect(form.state).toMatchObject({ + values: { name: 'Alice' }, + isTouched: false, + isDirty: false, + isPristine: true, + isValid: true, + }) + }) + + it('skips form validation before field creation when causeValidation is false', () => { + const validator = vi.fn(() => 'Name is too short') + const form = new InternalFormApi({ + defaultValues: { name: 'Alice' }, + validators: [{ triggers: ['change'], run: validator }], + }) + + form.setFieldValue('name', 'Al', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + causeValidation: false, + }) + + expect(validator).not.toHaveBeenCalled() + expect(form.state).toMatchObject({ + values: { name: 'Al' }, + errors: [], + isTouched: false, + isDirty: false, + isPristine: true, + }) + }) it('applies updater functions to the current field value', () => { const form = new InternalFormApi({ defaultValues: { count: 1 } }) diff --git a/packages/form-core/tests/FormApi/FormApi.public.test-d.ts b/packages/form-core/tests/FormApi/FormApi.public.test-d.ts index 096d273118..7b9c0c0e0d 100644 --- a/packages/form-core/tests/FormApi/FormApi.public.test-d.ts +++ b/packages/form-core/tests/FormApi/FormApi.public.test-d.ts @@ -2,11 +2,12 @@ import { describe, expectTypeOf, it } from 'vitest' import { formOptions } from '../../src' import type { CreateSubmitErrorMapFn, + CreatedErrorMap, + GetFormErrorTypes, OnSubmitError, ParseSubmitIssuesFn, StandardSchemaV1Issue, - ToFormErrorTypes, - ValidationErrorMap, + ValidationFailureItem, ValidationIssue, } from '../../src' @@ -24,8 +25,8 @@ describe('FormSubmitContext', () => { const errors = createErrorMap({ form: 'Submission failed' }) expectTypeOf(errors).toEqualTypeOf< - ValidationErrorMap & - OnSubmitError> + CreatedErrorMap & + OnSubmitError> >() errors.fields.name = 'Name is required' @@ -43,10 +44,10 @@ describe('FormSubmitContext', () => { type SubmitReturn = ReturnType> expectTypeOf< - ToFormErrorTypes<[], SubmitReturn>['formError'] + GetFormErrorTypes<[], SubmitReturn>['selfError'] >().toEqualTypeOf() expectTypeOf< - ToFormErrorTypes<[], SubmitReturn>['fieldError'] + GetFormErrorTypes<[], SubmitReturn>['subfieldError'] >().toEqualTypeOf() }) @@ -59,10 +60,10 @@ describe('FormSubmitContext', () => { type SubmitReturn = ReturnType> expectTypeOf< - ToFormErrorTypes<[], SubmitReturn>['formError'] + GetFormErrorTypes<[], SubmitReturn>['selfError'] >().toEqualTypeOf() expectTypeOf< - ToFormErrorTypes<[], SubmitReturn>['fieldError'] + GetFormErrorTypes<[], SubmitReturn>['subfieldError'] >().toEqualTypeOf() }) }) diff --git a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts index 609b4eca3f..2ce246c564 100644 --- a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -2,7 +2,8 @@ import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { formOptions } from '../../src/FormApi/formOptions.public' import type { AnyFieldApi } from '../../src/FieldApi/FieldApi.public' -import type { FormApi, OnSubmitError } from '../../src/FormApi/FormApi.public' +import type { FormApi } from '../../src/FormApi/FormApi.public' +import type { OnSubmitError } from '../../src/FormApi/formSubmit.public' import type { FormOptions, FormOptionsApi, @@ -227,7 +228,7 @@ describe('formOptions.strictSchema', () => { }) expectTypeOf(options.serverState).toEqualTypeOf< - ServerFormState | null | undefined + ServerFormState | null | undefined >() formOptions.strictSchema(schema, { @@ -526,7 +527,7 @@ describe('formOptions.looseSchema', () => { expectTypeOf(options.defaultValues).toEqualTypeOf() expectTypeOf(options.serverState).toEqualTypeOf< - ServerFormState | null | undefined + ServerFormState | null | undefined >() formOptions.looseSchema(schema, { @@ -700,7 +701,7 @@ describe('formOptions server state', () => { it('accepts omitted, null, and initial server state without validators', () => { const options = formOptions({ defaultValues: { name: '' } }) expectTypeOf(options.serverState).toEqualTypeOf< - ServerFormState<{ name: string }, unknown> | null | undefined + ServerFormState<{ name: string }> | null | undefined >() formOptions({ defaultValues: { name: '' }, serverState: null }) diff --git a/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts index d534f7bd85..7175a12caf 100644 --- a/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts +++ b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts @@ -8,7 +8,7 @@ describe('runSubmissionProcess', () => { defaultValues: { name: '' }, onSubmit: ({ createErrorMap }) => { const errors = createErrorMap({ form: 'Initial submit error' }) - errors.form = 'Submission failed' + errors.form = ['Submission failed'] errors.fields.name = 'Name is required' return errors }, diff --git a/packages/form-core/tests/FormApi/submission-handling.spec.ts b/packages/form-core/tests/FormApi/submission-handling.spec.ts index 5540773a60..25b34be8ec 100644 --- a/packages/form-core/tests/FormApi/submission-handling.spec.ts +++ b/packages/form-core/tests/FormApi/submission-handling.spec.ts @@ -198,11 +198,11 @@ describe('form - submission handling', () => { }) const form = new InternalFormApi({ defaultValues: { name: '' }, - onSubmit: ({ value, createErrorMap }) => { + onSubmit: ({ value, parseIssues }) => { const result = schema.safeParse(value) if (!result.success) { - return createErrorMap(result.error.issues) + return parseIssues(result.error.issues) } return null @@ -480,14 +480,14 @@ describe('form - submission handling', () => { }) it('clears prior schema output when a dynamic submit predicate skips the validator', async () => { - let shouldRunOnSubmit = true + let shouldSkipOnSubmit = false const onSubmit = vi.fn() const form = new InternalFormApi({ defaultValues: { name: 'test' }, validators: [ { run: z.object({ name: z.string() }), - runOnSubmit: () => shouldRunOnSubmit, + skipOnSubmit: () => shouldSkipOnSubmit, triggers: [], }, ], @@ -499,7 +499,7 @@ describe('form - submission handling', () => { expect.objectContaining({ validatorOutputs: [{ name: 'test' }] }), ) - shouldRunOnSubmit = false + shouldSkipOnSubmit = true await form.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( @@ -592,7 +592,7 @@ describe('form - submission handling', () => { expect(thirdValidatorFn).not.toHaveBeenCalled() }) - it('skips validators with runOnSubmit: false', async () => { + it('skips validators with skipOnSubmit: true', async () => { const validatorFn = vi .fn() .mockImplementation(() => ({ message: 'Should not run' })) @@ -600,7 +600,7 @@ describe('form - submission handling', () => { defaultValues: { name: '' }, validators: [ { - runOnSubmit: false, + skipOnSubmit: true, run: validatorFn, triggers: ['change'], }, @@ -611,7 +611,7 @@ describe('form - submission handling', () => { expect(validatorFn).not.toHaveBeenCalled() }) - it('runs validators with runOnSubmit: true on submit', async () => { + it('runs validators with skipOnSubmit: false on submit', async () => { const validatorFn = vi .fn() .mockImplementation(() => ({ message: 'Submit error' })) @@ -619,7 +619,7 @@ describe('form - submission handling', () => { defaultValues: { name: '' }, validators: [ { - runOnSubmit: true, + skipOnSubmit: false, run: validatorFn, triggers: [], }, @@ -630,7 +630,7 @@ describe('form - submission handling', () => { expect(validatorFn).toHaveBeenCalled() }) - it('runs validators with runOnSubmit: false on their triggers', async () => { + it('runs validators with skipOnSubmit: true on their triggers', async () => { const validatorFn = vi .fn() .mockImplementation(() => ({ message: 'Change error' })) @@ -638,19 +638,19 @@ describe('form - submission handling', () => { defaultValues: { name: '' }, validators: [ { - runOnSubmit: false, + skipOnSubmit: true, run: validatorFn, triggers: ['change'], }, ], }) await form.validate('change') - // runOnSubmit: false only affects submit, not the configured triggers + // skipOnSubmit only affects submit, not the configured triggers expect(form.state.errors).toEqual([{ message: 'Change error' }]) expect(validatorFn).toHaveBeenCalled() }) - it('skips validators with runOnSubmit: false and no triggers on submit', async () => { + it('skips validators with skipOnSubmit: true and no triggers on submit', async () => { const validatorFn = vi .fn() .mockImplementation(() => ({ message: 'Should not run' })) @@ -658,7 +658,7 @@ describe('form - submission handling', () => { defaultValues: { name: '' }, validators: [ { - runOnSubmit: false, + skipOnSubmit: true, run: validatorFn, triggers: [], }, @@ -669,7 +669,7 @@ describe('form - submission handling', () => { expect(validatorFn).not.toHaveBeenCalled() }) - it('supports runOnSubmit as a function', async () => { + it('evaluates skipOnSubmit against the current form values', async () => { const validatorFn = vi .fn() .mockImplementation(() => ({ message: 'Conditional submit error' })) @@ -677,7 +677,7 @@ describe('form - submission handling', () => { defaultValues: { name: '' }, validators: [ { - runOnSubmit: ({ formApi }) => formApi.state.values.name === '', + skipOnSubmit: ({ formApi }) => formApi.state.values.name !== '', run: validatorFn, triggers: [], }, @@ -687,7 +687,13 @@ describe('form - submission handling', () => { expect(form.state.errors).toEqual([ { message: 'Conditional submit error' }, ]) - expect(validatorFn).toHaveBeenCalled() + expect(validatorFn).toHaveBeenCalledOnce() + + form.setFieldValue('name', 'Tony') + await form.handleSubmit() + + expect(form.state.errors).toEqual([]) + expect(validatorFn).toHaveBeenCalledOnce() }) it('returns the same promise when handleSubmit is called multiple times during submission', async () => { @@ -733,15 +739,15 @@ describe('form - submission handling', () => { const form = new InternalFormApi({ defaultValues: { fieldA: '', fieldB: '' }, - onSubmit: ({ createValidationError }) => { + onSubmit: ({ createErrorMap }) => { if (doErrorA) { - return createValidationError({ + return createErrorMap({ fields: { fieldA: { message: 'Field A error' }, }, }) } - return createValidationError({ + return createErrorMap({ fields: { fieldB: { message: 'Field B error' }, }, @@ -772,8 +778,8 @@ describe('form - submission handling', () => { it('routes submit field errors through boundaries', async () => { const form = new InternalFormApi({ defaultValues: { range: { to: '' } }, - onSubmit: ({ createValidationError }) => - createValidationError({ + onSubmit: ({ createErrorMap }) => + createErrorMap({ fields: { 'range.to': { message: 'Submit end date error' }, }, @@ -795,9 +801,9 @@ describe('form - submission handling', () => { const form = new InternalFormApi({ defaultValues: { fieldA: '' }, - onSubmit: ({ createValidationError }) => { + onSubmit: ({ createErrorMap }) => { if (doError) { - return createValidationError({ + return createErrorMap({ fields: { fieldA: { message: 'Field A error' }, }, diff --git a/packages/form-core/tests/FormApi/validation.spec.ts b/packages/form-core/tests/FormApi/validation.spec.ts index 2a033fa399..c84c8d6b52 100644 --- a/packages/form-core/tests/FormApi/validation.spec.ts +++ b/packages/form-core/tests/FormApi/validation.spec.ts @@ -122,11 +122,11 @@ describe('form - validation', () => { defaultValues: { user: { name: '' } }, validators: [ { - run: ({ value, createErrorMap }) => { + run: ({ value, parseIssues }) => { const result = schema.safeParse(value) if (!result.success) { - return createErrorMap(result.error.issues) + return parseIssues(result.error.issues) } return null @@ -561,7 +561,7 @@ describe('form - validation', () => { }) }) - describe('runOnMount', () => { + describe('mount triggers', () => { it('does not run validators during construction by default', () => { const validatorFn = vi.fn().mockImplementation(() => 'Mount error') @@ -574,38 +574,57 @@ describe('form - validation', () => { expect(form.state.errors).toEqual([]) }) - it('does not run validators during construction when runOnMount is false', () => { + it('does not run validators during construction when the mount trigger is disabled', () => { const validatorFn = vi.fn().mockImplementation(() => 'Mount error') const form = new InternalFormApi({ defaultValues: { name: '' }, - validators: [{ run: validatorFn, runOnMount: false, triggers: [] }], + validators: [ + { run: validatorFn, triggers: [{ trigger: 'mount', when: false }] }, + ], }) expect(validatorFn).not.toHaveBeenCalled() expect(form.state.errors).toEqual([]) }) - it('runs validators during construction when runOnMount is true', () => { + it('runs validators during construction with a mount trigger', () => { const validatorFn = vi.fn().mockImplementation(() => 'Mount error') const form = new InternalFormApi({ defaultValues: { name: '' }, - validators: [{ run: validatorFn, runOnMount: true, triggers: [] }], + validators: [{ run: validatorFn, triggers: ['mount'] }], }) expect(validatorFn).toHaveBeenCalledOnce() expect(form.state.errors).toEqual([{ message: 'Mount error' }]) }) + it('runs validators with an enabled conditional mount trigger once', () => { + const validator = vi.fn(() => 'Mount error') + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + run: validator, + skipOnSubmit: true, + triggers: ['blur', { trigger: 'mount', when: true }, 'mount'], + }, + ], + }) + + expect(validator).toHaveBeenCalledOnce() + expect(form.state.errors).toEqual([{ message: 'Mount error' }]) + expect(form.state.isValidating).toBe(false) + }) + it('sets validity state from synchronous mount validation immediately', () => { const form = new InternalFormApi({ defaultValues: { name: '' }, validators: [ { run: () => ({ message: 'Mount error' }), - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -625,8 +644,7 @@ describe('form - validation', () => { name: 'Name is required', }, }), - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -644,8 +662,7 @@ describe('form - validation', () => { run: z.object({ name: z.string().min(1, 'Name is required'), }), - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -663,8 +680,7 @@ describe('form - validation', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: async () => { await new Promise((resolve) => setTimeout(resolve, 100)) return 'Async mount error' @@ -694,18 +710,15 @@ describe('form - validation', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: () => asyncResult, }, { - runOnMount: false, - triggers: [], + triggers: [{ trigger: 'mount', when: false }], run: skippedValidator, }, { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: secondValidator, }, ], @@ -734,14 +747,12 @@ describe('form - validation', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: () => asyncResult, }, { bailIfInvalid: true, - runOnMount: true, - triggers: [], + triggers: ['mount'], run: bailedValidator, }, ], @@ -762,8 +773,7 @@ describe('form - validation', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: validator, }, ], @@ -783,8 +793,7 @@ describe('form - validation', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: () => { throw error }, @@ -808,8 +817,7 @@ describe('form - validation', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: () => Promise.reject(error), }, ], @@ -842,8 +850,7 @@ describe('form - validation', () => { validators: [ { run: schema, - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -867,13 +874,11 @@ describe('form - validation', () => { validators: [ { run: skippedValidator, - runOnMount: false, - triggers: [], + triggers: [{ trigger: 'mount', when: false }], }, { run: mountValidator, - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -885,7 +890,6 @@ describe('form - validation', () => { it('ignores triggerDebounceMs for mount validation', () => { vi.useFakeTimers() - const triggerDebounceMs = vi.fn().mockReturnValue(100) const validatorFn = vi.fn().mockImplementation(() => 'Mount error') const form = new InternalFormApi({ @@ -893,15 +897,13 @@ describe('form - validation', () => { validators: [ { run: validatorFn, - runOnMount: true, - triggerDebounceMs, - triggers: ['change'], + triggerDebounceMs: 100, + triggers: ['mount', 'change'], }, ], }) expect(validatorFn).toHaveBeenCalledOnce() - expect(triggerDebounceMs).not.toHaveBeenCalled() expect(form.state.errors).toEqual([{ message: 'Mount error' }]) vi.useRealTimers() }) @@ -910,7 +912,7 @@ describe('form - validation', () => { const validatorFn = vi.fn().mockImplementation(() => 'Mount error') const form = new InternalFormApi({ defaultValues: { name: '' }, - validators: [{ run: validatorFn, runOnMount: true, triggers: [] }], + validators: [{ run: validatorFn, triggers: ['mount'] }], }) form.mount() @@ -927,14 +929,12 @@ describe('form - validation', () => { validators: [ { run: () => 'Mount error', - runOnMount: true, - triggers: [], + triggers: ['mount'], }, { bailIfInvalid: true, run: secondValidatorFn, - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -954,8 +954,7 @@ describe('form - validation', () => { name: 'Mount field error', }, }), - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) diff --git a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index d0b66e010d..fafa10353b 100644 --- a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts @@ -16,8 +16,7 @@ describe('FormGroupApi', () => { validators: [ { run: validator, - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -81,8 +80,7 @@ describe('FormGroupApi', () => { validators: [ { run: () => result, - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -889,7 +887,7 @@ describe('FormGroupApi', () => { }) it('clears prior group schema output when a dynamic submit predicate skips the validator', async () => { - let shouldRunOnSubmit = true + let shouldSkipOnSubmit = false const onSubmit = vi.fn() const form = new InternalFormApi({ defaultValues: { guestDetails: { name: 'Tony' } }, @@ -900,7 +898,7 @@ describe('FormGroupApi', () => { validators: [ { run: z.object({ name: z.string() }), - runOnSubmit: () => shouldRunOnSubmit, + skipOnSubmit: () => shouldSkipOnSubmit, triggers: [], }, ], @@ -912,7 +910,7 @@ describe('FormGroupApi', () => { expect.objectContaining({ validatorOutputs: [{ name: 'Tony' }] }), ) - shouldRunOnSubmit = false + shouldSkipOnSubmit = true await group.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( @@ -2030,11 +2028,11 @@ describe('FormGroupApi', () => { validators: [ { triggers: [], - run: ({ value, createErrorMap }) => { + run: ({ value, parseIssues }) => { const result = schema.safeParse(value) if (!result.success) { - return createErrorMap(result.error.issues) + return parseIssues(result.error.issues) } return null diff --git a/packages/form-core/tests/ValidatorInstance.spec.ts b/packages/form-core/tests/ValidatorInstance.spec.ts index a77caa3515..b74b0e7961 100644 --- a/packages/form-core/tests/ValidatorInstance.spec.ts +++ b/packages/form-core/tests/ValidatorInstance.spec.ts @@ -5,12 +5,13 @@ import { reconcileValidatorInstances, } from '../src/ValidatorInstance.lib' import { validationSourceScopes } from '../src/ValidationSourceInstance.lib' +import type { FieldValidator } from '../src' type TestDebouncedFn = (value: string) => void const createDefinition = (message: string) => ({ run: () => ({ message }), - triggers: ['change'] as const, + triggers: ['change' as const], }) function createInstance() { @@ -18,7 +19,7 @@ function createInstance() { const owner = { name: 'name' as const } return new InternalValidatorInstance< - typeof definition, + 'field', typeof owner, string, { name: string }, @@ -50,7 +51,10 @@ describe('InternalValidatorInstance', () => { const second = createInstance() expect(first).not.toBe(second) - expect(first.definition.run()).toEqual({ message: 'initial' }) + expect(first.definition).toMatchObject({ + run: expect.any(Function), + triggers: ['change'], + }) expect(first.owner).toEqual({ name: 'name' }) expect(first.scope).toBe(validationSourceScopes.field) expect(first.index).toBe(0) @@ -73,7 +77,7 @@ describe('InternalValidatorInstance', () => { expect(first.resolvedWatchFields).toBeNull() expectTypeOf(first.definition).toEqualTypeOf< - ReturnType + FieldValidator >() expectTypeOf(first.owner).toEqualTypeOf<{ name: 'name' }>() expectTypeOf(first.errorTargets).toEqualTypeOf | null>() diff --git a/packages/form-core/tests/serverValidate.spec.ts b/packages/form-core/tests/serverValidate.spec.ts index 5a6bb81f1a..dcde21d7ee 100644 --- a/packages/form-core/tests/serverValidate.spec.ts +++ b/packages/form-core/tests/serverValidate.spec.ts @@ -3,21 +3,17 @@ import { z } from 'zod' import { InternalFormApi } from '../src/FormApi/FormApi.lib' import { InternalValidatorInstance } from '../src/ValidatorInstance.lib' import { installDevtoolsBridge } from '../src/devtoolsBridge.lib' -import { validateServerValues } from '../src/internals' -import { formOptions, initialServerFormState } from '../src' +import { initialServerFormState, validateServerValues } from '../src/internals' +import { formOptions } from '../src' import type { - FormValidators, ServerValidateFailure, ServerValidateResult, ServerValidateSuccess, } from '../src' -function expectServerValidateSuccess< - TFormData, - TFormValidators extends FormValidators, ->( - result: ServerValidateResult, -): ServerValidateSuccess { +function expectServerValidateSuccess( + result: ServerValidateResult, +): ServerValidateSuccess { expect(result.success).toBe(true) if (!result.success) { @@ -27,12 +23,9 @@ function expectServerValidateSuccess< return result } -function expectServerValidateFailure< - TFormData, - TFormValidators extends FormValidators, ->( - result: ServerValidateResult, -): ServerValidateFailure { +function expectServerValidateFailure( + result: ServerValidateResult, +): ServerValidateFailure { expect(result.success).toBe(false) if (result.success) { @@ -149,7 +142,7 @@ describe('server validation', () => { expect(serverValidator).toHaveBeenCalledOnce() }) - it('skips server-only validators during client submit when runOnSubmit is false', async () => { + it('skips server-only validators during client submit when skipOnSubmit is true', async () => { const validator = vi.fn(() => 'Submit error') const form = new InternalFormApi( formOptions({ @@ -158,7 +151,7 @@ describe('server validation', () => { { run: validator, triggers: ['server'], - runOnSubmit: false, + skipOnSubmit: true, }, ], }), diff --git a/packages/form-core/tests/serverValidate.test-d.ts b/packages/form-core/tests/serverValidate.test-d.ts index a126b87415..80f2a180d3 100644 --- a/packages/form-core/tests/serverValidate.test-d.ts +++ b/packages/form-core/tests/serverValidate.test-d.ts @@ -1,157 +1,93 @@ import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { validateServerValues } from '../src/internals' -import { formOptions, serverValidateHelper } from '../src' +import { formOptions } from '../src' import type { FieldValidators, FormApi, FormGroupValidators, - FormOptions, - FormValidators, + FormValidationResult, ParsedStandardSchemaIssues, ServerFormState, - ServerValidateFrameworkPlugin, } from '../src' +type Values = { name: string } + describe('server validation types', () => { - it('accepts server validators and preserves schema output inference', async () => { + it('preserves server schema values and output inference', async () => { const options = formOptions({ defaultValues: { name: '' }, validators: [ { run: z - .object({ - name: z.string(), - }) + .object({ name: z.string() }) .transform(({ name }) => ({ nameLength: name.length })), triggers: ['server'], }, ], }) - const result = await validateServerValues(options, { name: 'Tony' }) + if (!result.success) throw new Error('Expected success') - if (!result.success) { - throw new Error('Expected server validation to succeed') - } - - expectTypeOf(result.values).toEqualTypeOf<{ name: string }>() + expectTypeOf(result.values).toEqualTypeOf() expectTypeOf(result.validatorOutputs).toEqualTypeOf< - readonly [{ nameLength: number }] + [{ nameLength: number }] + >() + expectTypeOf>().toExtend< + ServerFormState['validationResults'][number]['result'] >() - - type Values = { name: string } - type Validators = NonNullable - - type ValidationResult = ServerFormState< - Values, - Validators - >['validationResults'][number]['result'] - - expectTypeOf>().toExtend() }) - it('preserves validator generics on failed server validation results', async () => { + it('preserves the form value shape in failed server results', async () => { const options = formOptions({ defaultValues: { name: '' }, validators: [ { - run: () => ({ - fields: { - name: 'Name is required', - }, - }), + run: ({ createErrorMap }) => + createErrorMap({ fields: { name: 'Name is required' } }), triggers: ['server'], }, ], }) - const result = await validateServerValues(options, { name: '' }) + if (result.success) throw new Error('Expected failure') - if (result.success) { - throw new Error('Expected server validation to fail') - } - - type Values = { name: string } - type Validators = NonNullable - - expectTypeOf(result.serverState).toEqualTypeOf< - ServerFormState - >() - type Result = + expectTypeOf(result.serverState).toEqualTypeOf>() + expectTypeOf< (typeof result.serverState.validationResults)[number]['result'] - - expectTypeOf<{ - fields: { - name: 'Name is required' - } - }>().toExtend() + >().toEqualTypeOf>() }) - it('bases server state compatibility on server result types', () => { - type Values = { name: string } - interface BaseIssue { - message: string - } - interface DetailedIssue extends BaseIssue { - code: number - } - interface IncompatibleIssue extends BaseIssue { - reason: string + it('accepts server errors independently of client validator error shapes', () => { + const issue = { message: 'Invalid name', code: 400 } + const serverState: ServerFormState = { + values: { name: 'Tony' }, + validationResults: [{ validatorIndex: 0, result: issue, output: null }], + submissionAttempts: 1, } - const detailedOptions = formOptions({ - defaultValues: { name: '' }, - validators: [ - { - run: (): DetailedIssue => ({ message: 'Invalid name', code: 400 }), - triggers: ['server'], - }, - ], - }) - const baseOptions = formOptions({ - defaultValues: { name: '' }, - validators: [ - { - run: (): BaseIssue => ({ message: 'Invalid name' }), - triggers: ['server'], - }, - ], - }) - const incompatibleOptions = formOptions({ + formOptions({ defaultValues: { name: '' }, + serverState, validators: [ { - run: (): IncompatibleIssue => ({ - message: 'Invalid name', - reason: 'missing', - }), + run: () => ({ message: 'Different issue', reason: 'missing' }), triggers: ['server'], }, ], }) - type DetailedState = ServerFormState< - Values, - NonNullable - > - const detailedState = null as never as DetailedState - - formOptions({ - ...baseOptions, - serverState: detailedState, - }) - formOptions({ - ...incompatibleOptions, - // @ts-expect-error The serialized server error is incompatible. - serverState: detailedState, + defaultValues: { name: '' }, + serverState: { + ...serverState, + // @ts-expect-error Server values must match the form's value shape. + values: { name: 123 }, + }, }) }) - it('ignores schema outputs and client-only errors for state compatibility', () => { - type Values = { name: string } - + it('excludes schema outputs and client-only errors from server state types', () => { const lengthOptions = formOptions({ defaultValues: { name: '' }, validators: [ @@ -183,122 +119,81 @@ describe('server validation types', () => { ], }) - type LengthState = ServerFormState< - Values, - NonNullable - > - type UppercaseState = ServerFormState< - Values, - NonNullable - > - - expectTypeOf().toEqualTypeOf() + expectTypeOf(lengthOptions.serverState).toEqualTypeOf< + typeof uppercaseOptions.serverState + >() }) - it('makes formApi optional for validators with server triggers', () => { + it('exposes the client form only after narrowing the validation event', () => { formOptions({ defaultValues: { name: '' }, validators: [ { - run: (ctx) => { - expectTypeOf(ctx.event).toEqualTypeOf< - 'change' | 'blur' | 'submit' | 'server' - >() - expectTypeOf(ctx.value).toEqualTypeOf<{ name: string }>() - expectTypeOf(ctx.formApi).toEqualTypeOf< - FormApi<{ name: string }, any> | undefined - >() - // @ts-expect-error formApi can be undefined for server-triggered validators. - void ctx.formApi.state.values - return undefined - }, - triggers: ['server'], - }, - { - run: ({ formApi }) => { - expectTypeOf(formApi).toEqualTypeOf< - FormApi<{ name: string }, any> | undefined - >() - return undefined - }, triggers: ['server', 'change'], - }, - { - run: ({ formApi }) => { - expectTypeOf(formApi).toEqualTypeOf< - FormApi<{ name: string }, any> | undefined + run: (context) => { + expectTypeOf(context.event).toEqualTypeOf< + 'server' | 'change' | 'submit' >() - return undefined + expectTypeOf(context.value).toEqualTypeOf() + if (context.event === 'server') { + // @ts-expect-error Server validators do not receive a client form. + void context.formApi + } else { + expectTypeOf(context.formApi).toEqualTypeOf< + FormApi + >() + expectTypeOf(context.signal).toEqualTypeOf() + } }, - triggers: [], }, { - run: ({ formApi }) => { - expectTypeOf(formApi).toEqualTypeOf< - FormApi<{ name: string }, any> | undefined - >() - return undefined - }, - runOnSubmit: ({ formApi }) => { - expectTypeOf(formApi.state.values).toEqualTypeOf<{ - name: string - }>() - return true - }, - triggerDebounceMs: ({ formApi }) => { - expectTypeOf(formApi.state.values).toEqualTypeOf<{ - name: string - }>() - return 0 - }, triggers: [], - }, - { - run: () => { - return undefined + skipOnSubmit: ({ formApi }) => { + expectTypeOf(formApi.state.values).toEqualTypeOf() + return false + }, + triggerDebounceMs: 100, + run: ({ formApi }) => { + expectTypeOf(formApi).toEqualTypeOf>() }, - triggers: [], }, ], }) }) - it('rejects server trigger configs and non-form server validators', () => { + it('accepts boolean conditions on server triggers and rejects callbacks', () => { formOptions({ defaultValues: { name: '' }, validators: [ + { run: () => undefined, triggers: [{ trigger: 'server', when: true }] }, { run: () => undefined, - triggers: [ - { - // @ts-expect-error Server triggers cannot be configured. - trigger: 'server', - }, - ], + triggers: [{ trigger: 'server', when: false }], }, { run: () => undefined, - triggers: [], - // @ts-expect-error Server validation is configured with triggers. - runOnServer: true, + // @ts-expect-error Server trigger conditions must be booleans. + triggers: [{ trigger: 'server', when: () => true }], }, ], }) + }) - const fieldValidators: FieldValidators<{ name: string }, 'name', string> = [ + it('rejects server triggers on field and group validators', () => { + const fieldValidators: FieldValidators = [ { run: () => undefined, triggers: [ - // @ts-expect-error Field validators cannot use server triggers. + // @ts-expect-error Server triggers belong to form validators. 'server', ], }, ] - const groupValidators: FormGroupValidators<{ name: string }> = [ + const groupValidators: FormGroupValidators = [ { run: () => undefined, triggers: [ - // @ts-expect-error Form group validators cannot use server triggers. + // @ts-expect-error Server triggers belong to form validators. 'server', ], }, @@ -307,94 +202,4 @@ describe('server validation types', () => { void fieldValidators void groupValidators }) - - it('preserves plugin-customized validator return signatures', () => { - const plugin = { - id: 'react-form-test', - createServerValidate: < - TFormData, - const TFormValidators extends FormValidators, - TSubmitReturn, - >( - _options: FormOptions< - TFormData, - TFormValidators, - TSubmitReturn, - unknown - >, - ) => { - return (values: TFormData) => Promise.resolve(values) - }, - } satisfies ServerValidateFrameworkPlugin - - const { createServerValidate } = serverValidateHelper({ framework: plugin }) - const validate = createServerValidate( - formOptions({ - defaultValues: { name: '' }, - validators: [ - { - run: () => undefined, - triggers: ['server'], - }, - ], - }), - ) - - expectTypeOf(validate).parameter(0).toEqualTypeOf<{ name: string }>() - }) - - it('preserves plugin-customized validator options and form-bound helpers', () => { - const plugin = { - id: 'react-form-test', - createServerValidate: < - TFormData, - const TFormValidators extends FormValidators, - TSubmitReturn, - >( - _options: FormOptions< - TFormData, - TFormValidators, - TSubmitReturn, - unknown - >, - _pluginOptions?: { field: keyof TFormData }, - ) => { - return Object.assign((values: TFormData) => Promise.resolve(values), { - getState: () => - Promise.resolve( - null as never as ServerFormState, - ), - }) - }, - } satisfies ServerValidateFrameworkPlugin - - const options = formOptions({ - defaultValues: { name: '', age: 0 }, - validators: [ - { - run: () => undefined, - triggers: ['server'], - }, - ], - }) - - const { createServerValidate } = serverValidateHelper({ framework: plugin }) - const validate = createServerValidate(options, { field: 'age' }) - - // @ts-expect-error Plugin options are typed against the form values. - createServerValidate(options, { field: 'missing' }) - - expectTypeOf(validate).parameter(0).toEqualTypeOf<{ - name: string - age: number - }>() - expectTypeOf(validate.getState()).toEqualTypeOf< - Promise< - ServerFormState< - { name: string; age: number }, - NonNullable - > - > - >() - }) }) diff --git a/packages/form-core/tests/standardSchema.spec.ts b/packages/form-core/tests/standardSchema.spec.ts index 870e8bb769..661c7a0213 100644 --- a/packages/form-core/tests/standardSchema.spec.ts +++ b/packages/form-core/tests/standardSchema.spec.ts @@ -163,7 +163,7 @@ describe('standard schema validation', () => { 'form', ) - expect(result).toEqual({ + expect(result).toMatchObject({ form: issues, fields: { 'users[0].name': issues, @@ -181,7 +181,7 @@ describe('standard schema validation', () => { const result = await parseStandardSchema(schema, { name: '' }, 'form') - expect(result).toEqual({ + expect(result).toMatchObject({ form: issues, fields: { name: [issues[2]], diff --git a/packages/form-core/tests/validation-output.test-d.ts b/packages/form-core/tests/validation-output.test-d.ts index 1f743f8e39..3e6d7b3d8d 100644 --- a/packages/form-core/tests/validation-output.test-d.ts +++ b/packages/form-core/tests/validation-output.test-d.ts @@ -6,11 +6,12 @@ import type { FieldValidatorContext, FieldValidators, FormGroupValidators, - FormValidators, - ToFormErrorTypes, + FormValidator, + GetFormErrorTypes, + FormOptionsValidators, ToFormGroupErrorTypes, ToFormGroupOutputs, - ToFormOutputs, + GetValidatorOutputs, ValidationErrorMap, ValidationIssue, ValidationOutput, @@ -18,10 +19,11 @@ import type { type Data = { name: string } -function defineValidators>( - validators: T, -): T { - return validators +function defineValidators< + const TConfig extends unknown[], + const TValidators extends ReadonlyArray, +>(validators: TValidators & FormOptionsValidators): TValidators { + return formOptions({ defaultValues: { name: '' }, validators }).validators! } function defineGroupValidators>( @@ -63,7 +65,7 @@ describe('validator output types', () => { ], onSubmit: ({ validatorOutputs }) => { expectTypeOf(validatorOutputs).toEqualTypeOf< - readonly [{ name: string }, number, undefined, { name: string }] + [{ name: string }, number, undefined, { name: string } | undefined] >() }, }) @@ -84,25 +86,26 @@ describe('validator output types', () => { }, ]) - expectTypeOf>().toEqualTypeOf<{ - readonly formError: never - readonly fieldError: { message: string; code: number } + expectTypeOf>().toEqualTypeOf<{ + readonly selfError: never + readonly subfieldError: { message: string; code: number } }>() - expectTypeOf>().toEqualTypeOf< - readonly [ - { - message: string - fields: { name: { message: string; payloadOnly: boolean } } - }, + expectTypeOf>().toEqualTypeOf< + [ + | { + message: string + fields: { name: { message: string; payloadOnly: boolean } } + } + | undefined, ] >() const outputOnly = defineValidators([ { run: ({ createOutput }) => createOutput('data'), triggers: [] }, ]) - expectTypeOf>().toEqualTypeOf<{ - readonly formError: never - readonly fieldError: never + expectTypeOf>().toEqualTypeOf<{ + readonly selfError: never + readonly subfieldError: never }>() }) @@ -125,13 +128,8 @@ describe('validator output types', () => { { run: ({ createOutput }) => createOutput(undefined), triggers: [] }, ]) - expectTypeOf>().toEqualTypeOf< - readonly [ - number | undefined, - number | undefined, - number | undefined, - undefined, - ] + expectTypeOf>().toEqualTypeOf< + [number | undefined, number | undefined, number | undefined, undefined] >() }) @@ -145,8 +143,9 @@ describe('validator output types', () => { { run: ({ createOutput, - }): ValidationOutput | ValidationErrorMap => - createOutput(1), + }): + | ValidationOutput + | ValidationErrorMap => createOutput(1), triggers: [], }, { @@ -192,54 +191,58 @@ describe('validator output types', () => { }, ]) - expectTypeOf>().toEqualTypeOf< - readonly [ + expectTypeOf>().toEqualTypeOf< + [ + number | undefined, + number | undefined, + number | undefined, + number | undefined, number | undefined, number | undefined, - number, - number, number | undefined, - number, number | undefined, - number, ] >() - type Errors = ToFormErrorTypes - expectTypeOf().toExtend() - expectTypeOf().toExtend() - expectTypeOf().toExtend() + type Errors = GetFormErrorTypes + expectTypeOf().toExtend() + expectTypeOf().toExtend() + expectTypeOf().toExtend() }) - it('preserves submit eligibility and conservative broad validator outputs', () => { + it('preserves submit eligibility and broad validator output positions', () => { const validators = defineValidators([ { run: ({ createOutput }) => createOutput(1), triggers: [], - runOnSubmit: false, + skipOnSubmit: true, }, { run: ({ createOutput }) => createOutput(1), triggers: [], - runOnSubmit: true as boolean, + skipOnSubmit: false as boolean, }, { run: ({ createOutput }) => createOutput(1), triggers: [], - runOnSubmit: () => true, + skipOnSubmit: () => false, }, { run: ({ createOutput }) => createOutput(1), triggers: [], - runOnSubmit: true, + skipOnSubmit: false, }, ]) - expectTypeOf>().toEqualTypeOf< - readonly [undefined, number | undefined, number | undefined, number] - >() - expectTypeOf>>().toEqualTypeOf< - unknown[] + expectTypeOf>().toEqualTypeOf< + [undefined, number | undefined, number | undefined, number] >() + expectTypeOf< + GetValidatorOutputs< + ReadonlyArray< + FormValidator + > + > + >().toEqualTypeOf>() }) it('infers group outputs and excludes them from group errors', () => { @@ -257,8 +260,8 @@ describe('validator output types', () => { readonly [number] >() expectTypeOf>().toEqualTypeOf<{ - readonly formError: ValidationIssue - readonly fieldError: ValidationIssue + readonly selfError: ValidationIssue + readonly subfieldError: ValidationIssue }>() }) @@ -274,16 +277,14 @@ describe('validator output types', () => { run: async ({ value, createOutput }) => createOutput(value.name.length), triggers: ['server'], - runOnSubmit: false, + skipOnSubmit: true, }, ], }) const result = await validateServerValues(options, { name: 'Tony' }) if (!result.success) throw new Error('Expected success') - expectTypeOf(result.validatorOutputs).toEqualTypeOf< - readonly [undefined, number] - >() + expectTypeOf(result.validatorOutputs).toEqualTypeOf<[undefined, number]>() }) it('does not expose or accept branded outputs in field validators', () => { @@ -294,7 +295,7 @@ describe('validator output types', () => { run: () => ValidationOutput triggers: [] } - expectTypeOf().not.toExtend< + expectTypeOf<[OutputValidator]>().not.toExtend< FieldValidators >() }) diff --git a/packages/form-core/tests/validation-output.test.ts b/packages/form-core/tests/validation-output.test.ts index 6dac7bd53f..f3767a6be5 100644 --- a/packages/form-core/tests/validation-output.test.ts +++ b/packages/form-core/tests/validation-output.test.ts @@ -67,7 +67,7 @@ describe('validator outputs', () => { triggers: [], }, { run: () => undefined, triggers: [] }, - { run: skipped, triggers: [], runOnSubmit: false }, + { run: skipped, triggers: [], skipOnSubmit: true }, ], onSubmit, }), @@ -119,36 +119,34 @@ describe('validator outputs', () => { }) it('clears earlier outputs when a later submission skips or succeeds without output', async () => { - let shouldRun = true + let shouldSkipOnSubmit = false let produceOutput = true const onSubmit = vi.fn() - const form = new InternalFormApi( - formOptions({ - defaultValues: { name: 'Tony' }, - validators: [ - { - run: ({ value, createOutput }) => - produceOutput ? createOutput(value.name) : [], - runOnSubmit: () => shouldRun, - triggers: [], - }, - ], - onSubmit, - }), - ) + const form = new InternalFormApi({ + defaultValues: { name: 'Tony' }, + validators: [ + { + run: ({ value, createOutput }) => + produceOutput ? createOutput(value.name) : [], + skipOnSubmit: () => shouldSkipOnSubmit, + triggers: [], + }, + ], + onSubmit, + }) await form.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( expect.objectContaining({ validatorOutputs: ['Tony'] }), ) - shouldRun = false + shouldSkipOnSubmit = true await form.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( expect.objectContaining({ validatorOutputs: [undefined] }), ) - shouldRun = true + shouldSkipOnSubmit = false await form.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( expect.objectContaining({ validatorOutputs: ['Tony'] }), @@ -171,7 +169,7 @@ describe('validator outputs', () => { { run: ({ createOutput }) => createOutput('event output'), triggers: ['change', 'blur'], - runOnSubmit: false, + skipOnSubmit: true, }, { run: nextValidator, @@ -202,29 +200,25 @@ describe('validator outputs', () => { for (const asyncOutput of [false, true]) { const nextValidator = vi.fn(() => undefined) const onSubmit = vi.fn() - const form = new InternalFormApi( - formOptions({ - defaultValues: { name: 'Tony' }, - validators: [ - { - run: ({ createOutput }) => - asyncOutput - ? Promise.resolve(createOutput('mount output')) - : createOutput('mount output'), - runOnMount: true, - runOnSubmit: false, - triggers: [], - }, - { - run: nextValidator, - runOnMount: true, - triggers: [], - bailIfInvalid: true, - }, - ], - onSubmit, - }), - ) + const form = new InternalFormApi({ + defaultValues: { name: 'Tony' }, + validators: [ + { + run: ({ createOutput }) => + asyncOutput + ? Promise.resolve(createOutput('mount output')) + : createOutput('mount output'), + skipOnSubmit: true, + triggers: ['mount'], + }, + { + run: nextValidator, + triggers: ['mount'], + bailIfInvalid: true, + }, + ], + onSubmit, + }) const cleanup = form.mount() onTestFinished(cleanup) @@ -244,7 +238,7 @@ describe('validator outputs', () => { }) it('provides group outputs from fresh validation and clears skipped slots', async () => { - let shouldRun = true + let shouldSkipOnSubmit = false const onSubmit = vi.fn() const form = new InternalFormApi({ defaultValues: { guest: { name: ' Tony ' } }, @@ -256,9 +250,8 @@ describe('validator outputs', () => { { run: async ({ value, createOutput }) => createOutput({ name: value.name.trim() }), - runOnMount: true, - runOnSubmit: () => shouldRun, - triggers: ['change'], + skipOnSubmit: () => shouldSkipOnSubmit, + triggers: ['mount', 'change'], }, ], onSubmit, @@ -276,7 +269,7 @@ describe('validator outputs', () => { expect.objectContaining({ validatorOutputs: [{ name: 'Tony' }] }), ) - shouldRun = false + shouldSkipOnSubmit = true await group.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( expect.objectContaining({ validatorOutputs: [undefined] }), @@ -296,7 +289,7 @@ describe('validator outputs', () => { run: async ({ value, createOutput }) => createOutput(value.name.length), triggers: ['server'], - runOnSubmit: false, + skipOnSubmit: true, }, ], }) diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts index 3a7e082950..828c037406 100644 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -6,17 +6,19 @@ import { } from '../src/validation' import { InternalFormApi } from '../src/FormApi/FormApi.lib' import { reconcileValidatorInstances } from '../src/ValidatorInstance.lib' +import { formOptions } from '../src' import type { PipelineResult } from '../src/validation' import type { + ClientSideEvents, ClientValidationTrigger, DeepKeys, DeepValue, FieldValidateResult, FieldValidator, - FormValidateResult, + FormValidationResult, FormValidator, FormValidatorContext, - ToFormOutputs, + GetValidatorOutputs, ValidationDebounceFn, ValidationPredicateFn, } from '../src' @@ -31,8 +33,8 @@ describe('runFormValidatorPipeline', () => { } function getPipeline( - form: InternalFormApi, - pipeline: Array>, + form: InternalFormApi, + pipeline: Array>, ) { const validatorInstances = reconcileValidatorInstances({ definitions: pipeline, @@ -45,7 +47,7 @@ describe('runFormValidatorPipeline', () => { runWithContext: (args: { event: Event field?: AnyInternalFieldApi - onResult?: (result: PipelineResult>) => void + onResult?: (result: PipelineResult>) => void hasFailedBefore?: boolean }) => { return runFormValidatorPipeline({ @@ -68,7 +70,7 @@ describe('runFormValidatorPipeline', () => { TFieldName extends DeepKeys, TFieldValue extends DeepValue, >( - form: InternalFormApi, + form: InternalFormApi, field: AnyInternalFieldApi, pipeline: Array>, ) { @@ -122,10 +124,10 @@ describe('runFormValidatorPipeline', () => { const issues = [ { message: 'Invalid name', path: ['users', 0, 'name'] as const }, ] - const run = vi.fn(({ triggerFieldApi, value, createErrorMap }) => { + const run = vi.fn(({ triggerFieldApi, value, parseIssues }) => { expect(triggerFieldApi).toBe(field) expect(value).toBe(formApi.state.values) - expect(createErrorMap(issues)).toEqual({ + expect(parseIssues(issues)).toMatchObject({ form: issues, fields: { 'users[0].name': issues }, }) @@ -154,7 +156,7 @@ describe('runFormValidatorPipeline', () => { expect(fieldContext.fieldApi).toBe(field) expect(fieldContext).not.toHaveProperty('triggerFieldApi') expect(fieldContext.value).toBe(formApi.state.values) - expect(fieldContext.createErrorMap(issues)).toEqual({ + expect(fieldContext.parseIssues(issues)).toMatchObject({ form: issues, fields: { 'users[0].name': issues }, }) @@ -191,10 +193,8 @@ describe('runFormValidatorPipeline', () => { const results = await runWithContext({ event: 'submit' }) - expect(results).toEqual([ - expect.objectContaining({ - result: { fields: { name: 'Name is required' } }, - }), + expect(results).toMatchObject([ + { result: { fields: { name: 'Name is required' } } }, ]) }) @@ -294,59 +294,59 @@ describe('runFormValidatorPipeline', () => { expect(blurOrChange).toHaveBeenCalledTimes(2) }) - it('should only run validators on submit if enabled', async () => { + it('only runs validators on submit when skipOnSubmit allows it', async () => { const formApi = getForm({ name: '' }) - const trueByDefault = vi.fn() - const trueCallback = vi.fn() - const trueBoolean = vi.fn() + const runsByDefault = vi.fn() + const runsWithFalseCallback = vi.fn() + const runsWithFalse = vi.fn() - const falseBoolean = vi.fn() - const falseCallback = vi.fn() + const skipsWithTrue = vi.fn() + const skipsWithTrueCallback = vi.fn() const { runWithContext } = getPipeline(formApi, [ { - run: trueByDefault, + run: runsByDefault, triggers: [], }, { - run: trueCallback, - runOnSubmit: () => true, + run: runsWithFalseCallback, + skipOnSubmit: () => false, triggers: [], }, { - run: trueBoolean, - runOnSubmit: true, + run: runsWithFalse, + skipOnSubmit: false, triggers: [], }, { - run: falseBoolean, - runOnSubmit: false, + run: skipsWithTrue, + skipOnSubmit: true, triggers: [], }, { - run: falseCallback, - runOnSubmit: () => false, + run: skipsWithTrueCallback, + skipOnSubmit: () => true, triggers: [], }, ]) await runWithContext({ event: 'change' }) await runWithContext({ event: 'blur' }) - expect(trueByDefault).not.toHaveBeenCalled() - expect(trueCallback).not.toHaveBeenCalled() - expect(trueBoolean).not.toHaveBeenCalled() + expect(runsByDefault).not.toHaveBeenCalled() + expect(runsWithFalseCallback).not.toHaveBeenCalled() + expect(runsWithFalse).not.toHaveBeenCalled() - expect(falseBoolean).not.toHaveBeenCalled() - expect(falseCallback).not.toHaveBeenCalled() + expect(skipsWithTrue).not.toHaveBeenCalled() + expect(skipsWithTrueCallback).not.toHaveBeenCalled() await runWithContext({ event: 'submit' }) - expect(trueByDefault).toHaveBeenCalledOnce() - expect(trueCallback).toHaveBeenCalledOnce() - expect(trueBoolean).toHaveBeenCalledOnce() + expect(runsByDefault).toHaveBeenCalledOnce() + expect(runsWithFalseCallback).toHaveBeenCalledOnce() + expect(runsWithFalse).toHaveBeenCalledOnce() - expect(falseBoolean).not.toHaveBeenCalled() - expect(falseCallback).not.toHaveBeenCalled() + expect(skipsWithTrue).not.toHaveBeenCalled() + expect(skipsWithTrueCallback).not.toHaveBeenCalled() }) it('should debounce non-submit validation', async () => { @@ -405,15 +405,15 @@ describe('runFormValidatorPipeline', () => { const field = form._getOrCreateFieldApi({ name: 'name' }) const run = vi.fn(() => ({ message: 'foo' })) const triggerDebounceMs = vi.fn(({ scope, formApi, fieldApi, value }) => { - expect(scope).toBe('form') + expect(scope).toBe('field') expect(formApi.state.values).toEqual({ name: 'test' }) expect(fieldApi).toBe(field) - expect(value).toEqual({ name: 'test' }) + expect(value).toBe('test') return 100 }) - const { runWithContext } = getPipeline(form, [ + const { runWithContext } = getFieldPipeline(form, field, [ { run, triggers: ['change'], @@ -421,7 +421,7 @@ describe('runFormValidatorPipeline', () => { }, ]) - const promise = runWithContext({ event: 'change', field }) + const promise = runWithContext({ event: 'change' }) expect(triggerDebounceMs).toHaveBeenCalledOnce() expect(run).not.toHaveBeenCalled() @@ -790,7 +790,7 @@ describe('runFormValidatorPipeline', () => { }) .transform(({ name }) => ({ upperName: name.toUpperCase() })) - type Output = ToFormOutputs< + type Output = GetValidatorOutputs< [ { run: typeof lengthSchema; triggers: [] }, { run: typeof uppercaseSchema; triggers: [] }, @@ -816,13 +816,13 @@ describe('runFormValidatorPipeline', () => { }) .transform(({ name }) => ({ nameLength: name.length })) - new InternalFormApi({ + formOptions({ defaultValues: { name: '' }, validators: [{ run: schema, triggers: [] }], onSubmit: ({ value, validatorOutputs }) => { expectTypeOf(value).toEqualTypeOf<{ name: string }>() expectTypeOf(validatorOutputs).toEqualTypeOf< - readonly [{ nameLength: number }] + [{ nameLength: number }] >() }, }) @@ -833,7 +833,7 @@ describe('runFormValidatorPipeline', () => { }) .transform(({ name }) => ({ date: new Date(name.length) })) - new InternalFormApi({ + formOptions({ defaultValues: { name: '' }, validators: [ { run: schema, triggers: [] }, @@ -843,7 +843,7 @@ describe('runFormValidatorPipeline', () => { onSubmit: ({ value, validatorOutputs }) => { expectTypeOf(value).toEqualTypeOf<{ name: string }>() expectTypeOf(validatorOutputs).toEqualTypeOf< - readonly [{ nameLength: number }, undefined, { date: Date }] + [{ nameLength: number }, undefined, { date: Date }] >() }, }) @@ -1056,7 +1056,7 @@ describe('runFormValidatorPipeline', () => { it('aborts pending schema output before a dynamic predicate skips the validator', async () => { const formApi = getForm({ name: 'stale' }) - let shouldRunOnSubmit = true + let shouldSkipOnSubmit = false let resolveSchema!: (result: { value: { name: string } }) => void const output = new Promise<{ value: { name: string } }>((resolve) => { resolveSchema = resolve @@ -1071,13 +1071,13 @@ describe('runFormValidatorPipeline', () => { const { runWithContext, pipeline } = getPipeline(formApi, [ { run: schema, - runOnSubmit: () => shouldRunOnSubmit, + skipOnSubmit: () => shouldSkipOnSubmit, triggers: [], }, ]) const staleValidation = runWithContext({ event: 'submit' }) - shouldRunOnSubmit = false + shouldSkipOnSubmit = true await runWithContext({ event: 'submit' }) await staleValidation @@ -1282,7 +1282,7 @@ describe('runFormValidatorPipeline', () => { ]) }) - it('should not run zod schema validators with runOnSubmit disabled', async () => { + it('does not run zod schema validators with skipOnSubmit: true', async () => { const formApi = getForm({ name: '' }) const schema = z.object({ @@ -1293,7 +1293,7 @@ describe('runFormValidatorPipeline', () => { { run: schema, triggers: [], - runOnSubmit: false, + skipOnSubmit: true, }, ]) diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index c14596435c..7cef1edd30 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -9,7 +9,6 @@ import { } from '../src' import type { AnyFieldApi, - CreateSubmitErrorMapFn, ErrorVisibilityFieldState, FieldApiOptions, FieldErrors, @@ -19,17 +18,20 @@ import type { FormErrors, FormGroupValidators, FormOptions, + FormOptionsValidators, FormState, FormSubmitInvalidContext, - FormValidators, + GetFormErrorTypes, + GetValidatorOutputs, OnSubmitError, + ParseSubmitIssuesFn, ReusableErrorVisibilityState, + ScopedErrorTypes, StandardSchemaV1Issue, ToFieldError, ToFormErrorTypes, ToFormGroupErrorTypes, ToFormGroupOutputs, - ToFormOutputs, ValidationIssue, } from '../src' @@ -41,9 +43,12 @@ type TestFormData = { // Hopefully this identity function approach isn't too much of a headache in the future. function defineFormValidators< - const TValidators extends FormValidators, ->(validators: TValidators): TValidators { - return validators + const TConfig extends unknown[], + const TValidators extends ReadonlyArray, +>( + validators: TValidators & FormOptionsValidators, +): TValidators { + return formOptions({ defaultValues: { name: '' }, validators }).validators! } function defineFieldValidators< @@ -62,20 +67,22 @@ const emptyFormValidators = defineFormValidators([]) const emptyFieldValidators = defineFieldValidators([]) type TestFormErrors< - TFormValidators extends FormValidators, + TFormValidators extends ReadonlyArray, TSubmitReturn, -> = FormErrors> +> = FormErrors< + ToFormErrorTypes> +> type TestFieldErrors< TFieldValidators extends FieldValidators, TGroupValidators extends FormGroupValidators, - TFormValidators extends FormValidators, + TFormValidators extends ReadonlyArray, TSubmitReturn, > = FieldErrors< ToFieldError< TFieldValidators, - ToFormGroupErrorTypes['fieldError'], - ToFormErrorTypes + ToFormGroupErrorTypes['subfieldError'], + ToFormErrorTypes> > > @@ -117,9 +124,7 @@ describe('formOptions', () => { validators: [{ run: schema, triggers: ['change'] }], onSubmit: ({ value, validatorOutputs }) => { expectTypeOf(value).toEqualTypeOf<{ age: string }>() - expectTypeOf(validatorOutputs).toEqualTypeOf< - readonly [{ age: number }] - >() + expectTypeOf(validatorOutputs).toEqualTypeOf<[{ age: number }]>() }, }) @@ -161,7 +166,7 @@ describe('formOptions', () => { onSubmit: ({ value, validatorOutputs }) => { expectTypeOf(value).toEqualTypeOf() expectTypeOf(validatorOutputs).toEqualTypeOf< - readonly [undefined, { name: string; age: number }, undefined] + [undefined, { name: string; age: number }, undefined] >() }, }) @@ -184,7 +189,7 @@ describe('formOptions', () => { }, ], onSubmit: ({ validatorOutputs }) => { - expectTypeOf(validatorOutputs).toEqualTypeOf() + expectTypeOf(validatorOutputs).toEqualTypeOf<[undefined]>() }, }) @@ -375,6 +380,7 @@ describe('ErrorVisibility', () => { it('types callback scoped and pre-visibility field state', () => { const options: FormOptions< TestFormData, + [], typeof emptyFormValidators, never, unknown @@ -382,10 +388,7 @@ describe('ErrorVisibility', () => { defaultValues: { name: '' }, errorVisibility: ({ state, fieldState }) => { expectTypeOf(state).toEqualTypeOf< - FormState< - TestFormData, - ToFormErrorTypes - > + FormState >() expectTypeOf(fieldState).toEqualTypeOf() expectTypeOf(fieldState.value).toEqualTypeOf() @@ -405,7 +408,7 @@ describe('ErrorVisibility', () => { typeof emptyFieldValidators, [], TestFormData, - ToFormErrorTypes + GetFormErrorTypes > = { name: 'name', errorVisibility: ({ fieldState }) => fieldState.value === '', @@ -471,6 +474,7 @@ describe('ErrorVisibility', () => { it('rejects removed string presets', () => { const formOpts: FormOptions< TestFormData, + [], typeof emptyFormValidators, never, unknown @@ -486,7 +490,7 @@ describe('ErrorVisibility', () => { typeof emptyFieldValidators, [], TestFormData, - ToFormErrorTypes + GetFormErrorTypes > = { name: 'name', // @ts-expect-error String visibility presets were replaced by callbacks. @@ -570,8 +574,7 @@ describe('ValidationTriggerOption', () => { { trigger: 'change', when: (context) => { - expectTypeOf(context.scope).toEqualTypeOf<'form'>() - expectTypeOf(context.groupApi).toEqualTypeOf() + expectTypeOf(context.value).toEqualTypeOf() expectTypeOf(context.fieldApi).toEqualTypeOf< AnyFieldApi | undefined >() @@ -638,47 +641,45 @@ describe('ValidationTriggerOption', () => { }) }) - it('allows string server triggers on form validators only', () => { - defineFormValidators([ - { - run: ({ formApi }) => { - expectTypeOf(formApi).toEqualTypeOf< - FormApi | undefined - >() - // @ts-expect-error formApi can be undefined for server-triggered validators. - void formApi.state.values - return undefined - }, - triggers: ['server'], - }, - { - run: ({ formApi }) => { - expectTypeOf(formApi).toEqualTypeOf< - FormApi | undefined - >() - return undefined + it('allows static server triggers on form validators only', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: (context) => { + if (context.event === 'server') { + // @ts-expect-error Server validation does not expose a client form instance. + void context.formApi + } else { + expectTypeOf(context.formApi).toEqualTypeOf< + FormApi + >() + } + return undefined + }, + triggers: ['server'], }, - triggers: ['server', 'change'], - }, - { - run: ({ formApi }) => { - expectTypeOf(formApi).toEqualTypeOf< - FormApi | undefined - >() - return undefined + { + run: (context) => { + if (context.event !== 'server') { + expectTypeOf(context.formApi).toEqualTypeOf< + FormApi + >() + } + return undefined + }, + triggers: ['server', 'change'], }, - triggers: ['change'], - }, - { - run: () => undefined, - triggers: [ - { - // @ts-expect-error Server triggers cannot be configured. - trigger: 'server', + { + run: ({ formApi }) => { + expectTypeOf(formApi).toEqualTypeOf>() + return undefined }, - ], - }, - ]) + triggers: ['change'], + }, + { run: () => undefined, triggers: [{ trigger: 'server', when: true }] }, + ], + }) defineFieldValidators([ { @@ -736,7 +737,7 @@ describe('ValidationTriggerOption', () => { describe('FormErrors', () => { it('exposes only value and formApi in the invalid submit context', () => { expectTypeOf< - keyof FormSubmitInvalidContext + keyof FormSubmitInvalidContext >().toEqualTypeOf<'value' | 'formApi'>() const form = new InternalFormApi({ @@ -755,11 +756,9 @@ describe('FormErrors', () => { void form }) - it('should fall back omitted submit errors to ValidationIssue submit meta', () => { - expectTypeOf>().not.toBeAny() - expectTypeOf>().toEqualTypeOf< - Array - >() + it('does not infer submit errors from an unknown return', () => { + expectTypeOf>().not.toBeAny() + expectTypeOf>().toEqualTypeOf>() }) it('should allow inference for a form', () => { @@ -767,19 +766,18 @@ describe('FormErrors', () => { triggers: [ { trigger: 'change', - when: ({ formApi }) => formApi.state.submissionAttempts > 0, + when: ({ formApi }: { formApi: FormApi }) => + formApi.state.submissionAttempts > 0, }, ], - runOnSubmit: true, + skipOnSubmit: false, }) - const form = new InternalFormApi({ + const form = formOptions({ defaultValues: { name: '' }, validators: [withRequiredValue(z.object({ name: z.string() }))], onSubmit: ({ validatorOutputs }) => { - expectTypeOf(validatorOutputs).toEqualTypeOf< - readonly [{ name: string }] - >() + expectTypeOf(validatorOutputs).toEqualTypeOf<[{ name: string }]>() }, }) @@ -792,19 +790,21 @@ describe('FormErrors', () => { triggers: [ { trigger: 'change', - when: ({ formApi }) => formApi.state.submissionAttempts > 0, + when: ({ formApi }: { formApi: FormApi }) => + formApi.state.submissionAttempts > 0, }, ], - runOnSubmit: true, + skipOnSubmit: false, }, { triggers: [ { trigger: 'change', - when: ({ formApi }) => formApi.state.submissionAttempts > 0, + when: ({ formApi }: { formApi: FormApi }) => + formApi.state.submissionAttempts > 0, }, ], - runOnSubmit: true, + skipOnSubmit: false, bailIfInvalid: true, }, ]) @@ -836,12 +836,12 @@ describe('FormErrors', () => { expectTypeOf(validators.length).toEqualTypeOf<2>() expectTypeOf(validators[1].bailIfInvalid).toEqualTypeOf() - const form = new InternalFormApi({ + const form = formOptions({ defaultValues: { name: '' }, - validators, + validators: [...validators], onSubmit: ({ validatorOutputs }) => { expectTypeOf(validatorOutputs).toEqualTypeOf< - readonly [{ name: string }, undefined] + [{ name: string }, undefined] >() }, }) @@ -949,7 +949,7 @@ describe('FormErrors', () => { it('should infer form errors from parsed Standard Schema issues', () => { const validators = defineFormValidators([ { - run: ({ createErrorMap }) => createErrorMap([{ message: '' }]), + run: ({ parseIssues }) => parseIssues([{ message: '' }]), triggers: [], }, ]) @@ -1069,7 +1069,7 @@ describe('FormErrors', () => { }) it('should infer submit errors from parsed Standard Schema issues', () => { - type SubmitReturn = ReturnType> + type SubmitReturn = ReturnType> expectTypeOf>().toEqualTypeOf< Array @@ -1077,8 +1077,8 @@ describe('FormErrors', () => { new InternalFormApi({ defaultValues: { name: '' }, - onSubmit: ({ createErrorMap }) => { - return createErrorMap([{ message: '' }]) + onSubmit: ({ parseIssues }) => { + return parseIssues([{ message: '' }]) }, }) }) @@ -1108,7 +1108,9 @@ describe('FormErrors', () => { }) it('should ignore broad validators and preserve the submit fallback', () => { - expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf< + Array + >() expectTypeOf>().toEqualTypeOf< Array @@ -1292,8 +1294,8 @@ describe('FieldErrors', () => { const formValidators = defineFormValidators([ { - run: ({ createErrorMap }) => - createErrorMap([{ message: '', path: ['name'] }]), + run: ({ parseIssues }) => + parseIssues([{ message: '', path: ['name'] }]), triggers: [], }, ]) @@ -1322,8 +1324,8 @@ describe('FieldErrors', () => { it('should infer field errors from group parsed Standard Schema issues', () => { const groupValidators = defineGroupValidators([ { - run: ({ createErrorMap }) => - createErrorMap([{ message: '', path: ['name'] }]), + run: ({ parseIssues }) => + parseIssues([{ message: '', path: ['name'] }]), triggers: [], }, ]) @@ -1487,16 +1489,16 @@ describe('validator type transforms', () => { }, ]) - type Outputs = ToFormOutputs - type Errors = ToFormErrorTypes + type Outputs = GetValidatorOutputs + type Errors = GetFormErrorTypes - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf<[{ name: string }]>() expectTypeOf().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() }) - it('it should transform based on runOnSubmit', () => { + it('it should transform based on skipOnSubmit', () => { const vs = defineFormValidators([ { run: z.object({ name: z.string() }), @@ -1505,12 +1507,12 @@ describe('validator type transforms', () => { { run: z.object({ name: z.string() }), triggers: [], - runOnSubmit: false, + skipOnSubmit: true, }, { run: z.object({ name: z.string() }), triggers: [], - runOnSubmit: true, + skipOnSubmit: false, }, { run: z.object({ name: z.string() }), @@ -1519,7 +1521,7 @@ describe('validator type transforms', () => { { run: z.object({ name: z.string() }), triggers: ['server'], - runOnSubmit: true, + skipOnSubmit: false, }, { run: z.object({ name: z.string() }), @@ -1527,15 +1529,15 @@ describe('validator type transforms', () => { }, ]) - type Outputs = ToFormOutputs + type Outputs = GetValidatorOutputs expectTypeOf().toEqualTypeOf< - readonly [ - // Assume runOnSubmit is true if omitted. + [ + // Assume skipOnSubmit is false if omitted. { name: string }, - // It was explicitly set false. + // It was explicitly set true. undefined, - // Explicit true, server-only, and mixed triggers all run on client submit. + // Explicit false, server-only, and mixed triggers all run on client submit. { name: string }, { name: string }, { name: string }, @@ -1544,8 +1546,8 @@ describe('validator type transforms', () => { >() }) - it('makes schema outputs optional when runOnSubmit is dynamic', () => { - const dynamicRunOnSubmit = true as boolean + it('makes schema outputs optional when skipOnSubmit is dynamic', () => { + const dynamicSkipOnSubmit = false as boolean const vs = defineFormValidators([ { run: z.object({ name: z.string() }), @@ -1554,48 +1556,48 @@ describe('validator type transforms', () => { { run: z.object({ name: z.string() }), triggers: [], - runOnSubmit: true, + skipOnSubmit: false, }, { run: z.object({ name: z.string() }), triggers: [], - runOnSubmit: false, + skipOnSubmit: true, }, { run: z.object({ name: z.string() }), triggers: [], - runOnSubmit: dynamicRunOnSubmit, + skipOnSubmit: dynamicSkipOnSubmit, }, { run: z.object({ name: z.string() }), triggers: [], - runOnSubmit: () => true, + skipOnSubmit: () => false, }, { run: z.object({ name: z.string() }), triggers: [], - runOnSubmit: () => false, + skipOnSubmit: () => true, }, { run: z.object({ name: z.string() }), triggers: [], - runOnSubmit: () => dynamicRunOnSubmit, + skipOnSubmit: () => dynamicSkipOnSubmit, }, { run: z.object({ name: z.string() }), triggers: [], - runOnSubmit: undefined, + skipOnSubmit: undefined, }, ]) type Output = { name: string } - type Outputs = ToFormOutputs - type Expected = readonly [ - // An omitted runOnSubmit defaults to true, so the output is guaranteed. + type Outputs = GetValidatorOutputs + type Expected = [ + // An omitted skipOnSubmit defaults to false, so the output is guaranteed. Output, - // A literal true always runs the validator during submit. + // A literal false always runs the validator during submit. Output, - // A literal false always skips the validator during submit. + // A literal true always skips the validator during submit. undefined, // A broad boolean may skip the validator at runtime. Output | undefined, @@ -1603,7 +1605,7 @@ describe('validator type transforms', () => { Output | undefined, Output | undefined, Output | undefined, - // An explicit undefined receives the same true default as an omitted property. + // An explicit undefined receives the same false default as an omitted property. Output, ] @@ -1659,7 +1661,7 @@ describe('validator type transforms', () => { }, { run: schema, - runOnSubmit: false, + skipOnSubmit: true, triggers: [], }, ]) @@ -1668,7 +1670,7 @@ describe('validator type transforms', () => { type Outputs = ToFormGroupOutputs expectTypeOf().toEqualTypeOf< - FormErrorTypes< + ScopedErrorTypes< StandardSchemaV1Issue | { message: string; fromGroup: true }, StandardSchemaV1Issue | { message: string; fromGroupField: true } > @@ -1680,15 +1682,15 @@ describe('validator type transforms', () => { }) it('should normalize broad and empty validator transforms', () => { - expectTypeOf>().toEqualTypeOf< - FormErrorTypes + expectTypeOf>().toEqualTypeOf< + ScopedErrorTypes >() expectTypeOf>().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() expectTypeOf>().toEqualTypeOf>() expectTypeOf>().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() expectTypeOf>().toEqualTypeOf<[]>() }) diff --git a/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts b/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts index 99e81709c5..ef075543e7 100644 --- a/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts +++ b/packages/form-devtools/src/bridge/fields/fieldDebug/validatorsWithoutTriggers.ts @@ -11,7 +11,7 @@ function appendValidatorsWithoutTriggers( getLocation: (validatorIndex: number) => ValidatorLocation, ): void { validatorInstances?.forEach(({ definition: validator }, validatorIndex) => { - if (validator.triggers.length === 0 && !validator.runOnMount) { + if (!validator.triggers?.length) { locations.push(getLocation(validatorIndex)) } }) diff --git a/packages/form-devtools/src/components/fields/fieldDetails/fieldDebug/suspicionViews/validatorsWithoutTriggers.tsx b/packages/form-devtools/src/components/fields/fieldDetails/fieldDebug/suspicionViews/validatorsWithoutTriggers.tsx index 7a60448c95..b2a237a7c3 100644 --- a/packages/form-devtools/src/components/fields/fieldDetails/fieldDebug/suspicionViews/validatorsWithoutTriggers.tsx +++ b/packages/form-devtools/src/components/fields/fieldDetails/fieldDebug/suspicionViews/validatorsWithoutTriggers.tsx @@ -79,9 +79,8 @@ export function getValidatorsWithoutTriggersDetails( the validator's triggers array. , <> - If the validator is intentionally submit-only or mount-only, no trigger - is needed. Submit validation runs by default, while mount validation - requires runOnMount: true. + If the validator is intentionally submit-only, no trigger is needed. + Submit validation runs by default. , ], } diff --git a/packages/form-devtools/tests/fieldDebugCases.test.ts b/packages/form-devtools/tests/fieldDebugCases.test.ts index cde9a5679c..6772ebf4a1 100644 --- a/packages/form-devtools/tests/fieldDebugCases.test.ts +++ b/packages/form-devtools/tests/fieldDebugCases.test.ts @@ -45,8 +45,8 @@ describe('field debug cases', () => { emptyTriggerValidator, { ...emptyTriggerValidator, - runOnMount: true, - runOnSubmit: false, + triggers: ['mount'], + skipOnSubmit: true, }, ] as never, }) @@ -78,8 +78,8 @@ describe('field debug cases', () => { validators: [ { ...emptyTriggerValidator, - runOnMount: true, - runOnSubmit: false, + triggers: ['mount'], + skipOnSubmit: true, }, ] as never, }) diff --git a/packages/form-devtools/tests/fieldDebugInfoPopover.test.tsx b/packages/form-devtools/tests/fieldDebugInfoPopover.test.tsx index 8d7c976bdf..c62fea6de4 100644 --- a/packages/form-devtools/tests/fieldDebugInfoPopover.test.tsx +++ b/packages/form-devtools/tests/fieldDebugInfoPopover.test.tsx @@ -130,7 +130,6 @@ describe('field debug info popover', () => { expect(content()).toContain('change') expect(content()).toContain('blur') expect(content()).toContain('Submit validation runs by default') - expect(content()).toContain('runOnMount: true') }) it('requests field guidance, renders aggregated paths, and retains dismissals', async () => { diff --git a/packages/react-form-start/src/getFormData.ts b/packages/react-form-start/src/getFormData.ts index 4fd142903f..eeedf4ee5f 100644 --- a/packages/react-form-start/src/getFormData.ts +++ b/packages/react-form-start/src/getFormData.ts @@ -1,5 +1,5 @@ import { createServerFn } from '@tanstack/react-start' -import { initialServerFormState } from '@tanstack/form-core' +import { initialServerFormState } from '@tanstack/form-core/internals' import { deleteInternalTanStackCookie, getInternalTanStackCookie, From 43b6a3e49913c3e3dde07ad4b6df936c9220e145 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:36:07 +0200 Subject: [PATCH 21/43] fix: initial fixes for react-form --- .../src/app/booking/shared-form.tsx | 17 ++- .../form-core/src/FieldApi/FieldApi.public.ts | 6 +- packages/form-core/src/validation.public.ts | 12 +- packages/form-core/tests/validation.test-d.ts | 8 +- packages/preact-form/tests/useForm.spec.tsx | 10 +- packages/react-form-start/tests/start.spec.ts | 3 +- .../src/AppForm/ReactAppFormApi.lib.ts | 13 ++- .../src/AppForm/ReactAppFormApi.public.tsx | 6 +- .../src/AppForm/createFormHook.public.ts | 4 +- .../src/AppForm/createFormHookTypes.public.ts | 15 ++- .../src/AppForm/initializeAppForm.lib.ts | 4 +- .../src/FieldGroup/FieldGroupApi.public.ts | 10 +- .../src/ReactForm/Components.public.ts | 104 ++++++++---------- .../src/ReactForm/ReactFormApi.lib.tsx | 19 +--- .../src/ReactForm/formApiTypes.public.ts | 23 ++-- .../src/ReactForm/formType.public.ts | 13 +-- .../react-form/src/ReactForm/useField.lib.ts | 2 +- .../src/ReactForm/useForm.public.ts | 18 ++- .../react-form/tests/createFormHook.spec.tsx | 3 +- packages/react-form/tests/useForm.spec.tsx | 10 +- packages/vue-form/tests/adapter.spec.tsx | 3 +- 21 files changed, 155 insertions(+), 148 deletions(-) diff --git a/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx b/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx index ea4bc1da35..f7b86784c4 100644 --- a/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx +++ b/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx @@ -1,7 +1,7 @@ import { createValidator } from '@tanstack/react-form' import { bookingFormSchema } from './schema' -import type { ReactNode } from 'react' import type { ReactFormType } from '@tanstack/react-form' +import type { ReactNode } from 'react' import { appFormOptions } from '@/components/form/app-form' import { FieldGroup } from '@/components/ui/field' @@ -28,6 +28,7 @@ export function StepForm(props: StepFormProps) { ) } +// TODO bring back this config for forms? export const rewardEarlyPunishLate = createValidator({ triggers: [ 'blur', @@ -43,7 +44,19 @@ export const bookingFormOptions = appFormOptions.strictSchema( { errorVisibility: ({ state, fieldState }) => fieldState.meta.isBlurred || state.submissionAttempts > 0, - validators: [rewardEarlyPunishLate(bookingFormSchema)], + validators: [ + { + triggers: [ + 'blur', + { + trigger: 'change', + when: ({ fieldApi }) => + fieldApi !== undefined && fieldApi.meta.isInvalid, + }, + ], + run: bookingFormSchema, + }, + ], defaultValues: { guestDetails: { name: '', diff --git a/packages/form-core/src/FieldApi/FieldApi.public.ts b/packages/form-core/src/FieldApi/FieldApi.public.ts index 50af269fcf..33a2ee1565 100644 --- a/packages/form-core/src/FieldApi/FieldApi.public.ts +++ b/packages/form-core/src/FieldApi/FieldApi.public.ts @@ -245,11 +245,7 @@ export interface FieldApiOptions< TFieldData, TFieldName, TFieldValue, - ToFieldError< - NoInfer, - TGroupFieldError, - ToFormErrorTypes - >, + NoInfer>, TFormData, TFormError > diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/validation.public.ts index fa1b25673d..64fec2ca40 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/validation.public.ts @@ -806,10 +806,10 @@ type ExtractFormError = ? ValidationIssue : TFormErrorTypes['formError'] -type ExtractFormFieldError = - unknown extends TFormErrorTypes['fieldError'] +type ExtractFormFieldError = + unknown extends TFormError['subfieldError'] ? never - : TFormErrorTypes['fieldError'] + : TFormError['subfieldError'] /** * The form or group's error array, with string messages normalized to issue objects. @@ -999,14 +999,14 @@ type FallbackToValidationIssue = [TFieldError] extends [never] * * @typeParam TFieldValidators - Library-managed. Do not specify explicitly. * @typeParam TGroupFieldError - Library-managed. Do not specify explicitly. - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. + * @typeParam TFormError - Library-managed. Do not specify explicitly. */ export type ToFieldError< TFieldValidators extends FieldValidators, TGroupFieldError, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > = FallbackToValidationIssue< | ExtractValidatorFieldError> | (unknown extends TGroupFieldError ? never : TGroupFieldError) - | ExtractFormFieldError + | ExtractFormFieldError > diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index 7cef1edd30..f074fb0d86 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -82,7 +82,7 @@ type TestFieldErrors< ToFieldError< TFieldValidators, ToFormGroupErrorTypes['subfieldError'], - ToFormErrorTypes> + GetFormErrorTypes > > @@ -1620,7 +1620,7 @@ describe('validator type transforms', () => { }, ]) - type Error = ToFieldError> + type Error = ToFieldError> expectTypeOf().toEqualTypeOf<{ message: string @@ -1630,7 +1630,7 @@ describe('validator type transforms', () => { it('should fall back only after resolving all field error sources', () => { expectTypeOf< - ToFieldError<[], never, FormErrorTypes> + ToFieldError<[], never, ScopedErrorTypes> >().toEqualTypeOf() type FormFieldError = { @@ -1639,7 +1639,7 @@ describe('validator type transforms', () => { } expectTypeOf< - ToFieldError> + ToFieldError> >().toEqualTypeOf() }) diff --git a/packages/preact-form/tests/useForm.spec.tsx b/packages/preact-form/tests/useForm.spec.tsx index d5b353fb2d..502e2467f7 100644 --- a/packages/preact-form/tests/useForm.spec.tsx +++ b/packages/preact-form/tests/useForm.spec.tsx @@ -232,7 +232,7 @@ describe('useForm', () => { await expect.element(fieldA).toHaveValue('touched-a') }) - it('does not render with pre-validation state for synchronous runOnMount validation', () => { + it('does not render with pre-validation state for synchronous mount validation', () => { const renderStates: Array<{ errors: Array isValid: boolean @@ -244,8 +244,7 @@ describe('useForm', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: () => 'Name is required', }, ], @@ -327,7 +326,7 @@ describe('useForm', () => { .toHaveTextContent('true') }) - it('does not crash when asynchronous runOnMount validation resolves after unmount', async () => { + it('does not crash when asynchronous mount validation resolves after unmount', async () => { let resolveValidation!: (value: string) => void const validator = vi.fn( () => @@ -341,8 +340,7 @@ describe('useForm', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: validator, }, ], diff --git a/packages/react-form-start/tests/start.spec.ts b/packages/react-form-start/tests/start.spec.ts index 30574268ed..77232d84c4 100644 --- a/packages/react-form-start/tests/start.spec.ts +++ b/packages/react-form-start/tests/start.spec.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { parse } from 'devalue' -import { formOptions, initialServerFormState } from '@tanstack/form-core' +import { formOptions } from '@tanstack/form-core' +import { initialServerFormState } from '@tanstack/form-core/internals' import { getFormData, start } from '../src' const startMocks = vi.hoisted(() => ({ diff --git a/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts b/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts index c5cb5679b5..676b7ff884 100644 --- a/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts +++ b/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts @@ -6,7 +6,11 @@ import { createFieldWithContext, createFormGroupWithContext, } from './Components.lib' -import type { DefaultOptions, FormOptions } from '@tanstack/form-core' +import type { + AnyFormOptions, + DefaultOptions, + FormOptions, +} from '@tanstack/form-core' import type { FunctionComponent } from 'react' import type { AppFormComponent } from './ReactAppFormApi.public' import type { ReactComponentTree } from './componentMap.public' @@ -18,7 +22,7 @@ interface InternalReactAppFormApiInstance extends InternalReactFormApi { type InternalReactAppFormApiConstructor< TFormComponents extends ReactComponentTree, > = new ( - options: FormOptions, + options: AnyFormOptions, defaultOptions?: DefaultOptions, ) => InternalReactAppFormApiInstance & TFormComponents @@ -41,10 +45,7 @@ export function createInternalReactAppFormApiClass< return InternalReactAppFieldApi } - constructor( - options: FormOptions, - defaultOptions?: DefaultOptions, - ) { + constructor(options: AnyFormOptions, defaultOptions?: DefaultOptions) { super(options, defaultOptions) const field = createFieldWithContext(this, 'field') diff --git a/packages/react-form/src/AppForm/ReactAppFormApi.public.tsx b/packages/react-form/src/AppForm/ReactAppFormApi.public.tsx index be3a021858..b31c39e793 100644 --- a/packages/react-form/src/AppForm/ReactAppFormApi.public.tsx +++ b/packages/react-form/src/AppForm/ReactAppFormApi.public.tsx @@ -1,4 +1,4 @@ -import type { FormErrorTypes } from '@tanstack/form-core' +import type { FormErrorTypes, ScopedErrorTypes } from '@tanstack/form-core' import type { ReactFormApi } from '../ReactForm/formApiTypes.public' import type { FunctionComponent, ReactNode } from 'react' import type { AnyReactFormComponentMap } from './componentMap.public' @@ -9,8 +9,8 @@ export type AppFormComponent = FunctionComponent<{ export type ReactAppFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TComponents extends AnyReactFormComponentMap, -> = ReactFormApi & { +> = ReactFormApi & { AppForm: AppFormComponent } diff --git a/packages/react-form/src/AppForm/createFormHook.public.ts b/packages/react-form/src/AppForm/createFormHook.public.ts index dbed742b57..cb0038e3b3 100644 --- a/packages/react-form/src/AppForm/createFormHook.public.ts +++ b/packages/react-form/src/AppForm/createFormHook.public.ts @@ -22,7 +22,9 @@ export function createFormHook< }> { const initializeAppForm = createAppFormInitializer(createOptions) - function useExtendedForm(hookOptions: FormOptions) { + function useExtendedForm( + hookOptions: FormOptions, + ) { const form = useInternalForm(hookOptions, initializeAppForm) return form } diff --git a/packages/react-form/src/AppForm/createFormHookTypes.public.ts b/packages/react-form/src/AppForm/createFormHookTypes.public.ts index 2b5a0177e4..cd52500289 100644 --- a/packages/react-form/src/AppForm/createFormHookTypes.public.ts +++ b/packages/react-form/src/AppForm/createFormHookTypes.public.ts @@ -11,7 +11,7 @@ import type { DefaultFormOptions, FormOptions, FormOptionsApi, - FormValidators, + GetFormErrorTypes, ToFormErrorTypes, } from '@tanstack/form-core' @@ -109,13 +109,20 @@ export type UseAppFormHook< in out TComponents extends AnyReactFormComponentMap, > = < TFormData, - TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( - options: FormOptions, + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, ) => ReactAppFormApi< TFormData, - ToFormErrorTypes, + GetFormErrorTypes, TComponents > diff --git a/packages/react-form/src/AppForm/initializeAppForm.lib.ts b/packages/react-form/src/AppForm/initializeAppForm.lib.ts index d94de1d179..846f7c4aeb 100644 --- a/packages/react-form/src/AppForm/initializeAppForm.lib.ts +++ b/packages/react-form/src/AppForm/initializeAppForm.lib.ts @@ -2,11 +2,11 @@ import { createInternalReactAppFormApiClass } from './ReactAppFormApi.lib' import type { InternalReactFormApi } from '../ReactForm/ReactFormApi.lib' import type { ReactComponentTree } from './componentMap.public' import type { + AnyFormOptions, DefaultFieldOptions, DefaultFormGroupOptions, DefaultFormOptions, DefaultOptions, - FormOptions, } from '@tanstack/form-core' interface AnyCreateFormHookOptions { @@ -19,7 +19,7 @@ interface AnyCreateFormHookOptions { export function createAppFormInitializer( createOptions: AnyCreateFormHookOptions, -): (options: FormOptions) => InternalReactFormApi { +): (options: AnyFormOptions) => InternalReactFormApi { const InternalReactAppFormApi = createInternalReactAppFormApiClass( createOptions.formComponents, createOptions.fieldComponents, diff --git a/packages/react-form/src/FieldGroup/FieldGroupApi.public.ts b/packages/react-form/src/FieldGroup/FieldGroupApi.public.ts index 1f92c08793..dd37a2d805 100644 --- a/packages/react-form/src/FieldGroup/FieldGroupApi.public.ts +++ b/packages/react-form/src/FieldGroup/FieldGroupApi.public.ts @@ -5,8 +5,8 @@ import type { FieldValidators, FormApiArrayMethods, FormApiFieldMethods, - FormErrorTypes, FormState, + ScopedErrorTypes, ValidationIssue, } from '@tanstack/form-core' import type { FunctionComponent, ReactNode } from 'react' @@ -76,7 +76,7 @@ export interface FieldGroupFieldComponent< >, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents >, ): ReactNode @@ -138,7 +138,7 @@ export interface FieldGroupArrayFieldComponent< >, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents >, ): ReactNode @@ -146,7 +146,7 @@ export interface FieldGroupArrayFieldComponent< export type FieldGroupSubscribeProps = ReactFormSubscribeProps< unknown, - FormErrorTypes, + ScopedErrorTypes, TSelected > @@ -188,4 +188,4 @@ export type AnyFieldGroupApi = FieldGroupApi< Record> > -export type FieldGroupFormState = FormState +export type FieldGroupFormState = FormState diff --git a/packages/react-form/src/ReactForm/Components.public.ts b/packages/react-form/src/ReactForm/Components.public.ts index ff5db50dd0..1caefa7f30 100644 --- a/packages/react-form/src/ReactForm/Components.public.ts +++ b/packages/react-form/src/ReactForm/Components.public.ts @@ -11,6 +11,7 @@ import type { FormGroupState, FormGroupValidators, FormState, + ScopedErrorTypes, ToFieldError, ToFormGroupErrorTypes, } from '@tanstack/form-core' @@ -84,9 +85,9 @@ export type ReactFieldApi< TFieldValue, TFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends ReactComponentTree, -> = FieldApi & +> = FieldApi & FieldComponentsMatchingType /** @@ -101,15 +102,15 @@ interface ReactSubscribeProps { export type ReactFormSubscribeProps< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TSelected, -> = ReactSubscribeProps, TSelected> +> = ReactSubscribeProps, TSelected> export type ReactFormSubscribeComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, > = ( - props: ReactFormSubscribeProps, + props: ReactFormSubscribeProps, ) => ReactNode export interface ReactFormFieldProps< @@ -123,7 +124,7 @@ export interface ReactFormFieldProps< >, in out TGroupFieldError, in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends ReactComponentTree, > extends FieldApiOptions< TFieldData, @@ -132,19 +133,15 @@ export interface ReactFormFieldProps< TFieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes + TFormError > { children: ( fieldApi: ReactFieldApi< TFieldName, TFieldValue, - ToFieldError< - NoInfer, - TGroupFieldError, - TFormErrorTypes - >, + NoInfer>, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ) => ReactNode @@ -152,7 +149,7 @@ export interface ReactFormFieldProps< export type ReactFormFieldComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends ReactComponentTree, > = { < @@ -170,7 +167,7 @@ export type ReactFormFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): ReactNode @@ -178,7 +175,7 @@ export type ReactFormFieldComponent< export type ReactFormArrayFieldComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends ReactComponentTree, > = { < @@ -196,7 +193,7 @@ export type ReactFormArrayFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): ReactNode @@ -204,25 +201,22 @@ export type ReactFormArrayFieldComponent< export type ReactFormGroupSubscribeProps< TGroupValue, - TGroupErrorTypes extends FormErrorTypes, + TGroupError extends ScopedErrorTypes, TSelected, -> = ReactSubscribeProps< - FormGroupState, - TSelected -> +> = ReactSubscribeProps, TSelected> export type ReactFormGroupSubscribeComponent< in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, > = ( - props: ReactFormGroupSubscribeProps, + props: ReactFormGroupSubscribeProps, ) => ReactNode export interface ReactFormGroupFieldComponent< in out TFormData, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends ReactComponentTree, > { < @@ -238,9 +232,9 @@ export interface ReactFormGroupFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['subfieldError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): ReactNode @@ -249,8 +243,8 @@ export interface ReactFormGroupFieldComponent< export type ReactFormGroupArrayFieldComponent< in out TFormData, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends ReactComponentTree, > = { < @@ -266,9 +260,11 @@ export type ReactFormGroupArrayFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + // TODO this disparity between groups and forms is a bit weird. + // Stick to treating both the same when possible. + TGroupError['subfieldError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): ReactNode @@ -278,31 +274,31 @@ export interface ReactFormGroupApi< in out TFormData, in out TGroupName, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends ReactComponentTree, > extends FormGroupApi< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes + TGroupError, + TFormError > { Field: ReactFormGroupFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes, + TGroupError, + TFormError, TFieldComponents > ArrayField: ReactFormGroupArrayFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes, + TGroupError, + TFormError, TFieldComponents > - Subscribe: ReactFormGroupSubscribeComponent + Subscribe: ReactFormGroupSubscribeComponent } export interface ReactFormGroupProps< @@ -310,7 +306,7 @@ export interface ReactFormGroupProps< in out TGroupName, in out TGroupValue, in out TGroupValidators extends FormGroupValidators, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends ReactComponentTree, > extends Omit< FormGroupOptions< @@ -318,7 +314,7 @@ export interface ReactFormGroupProps< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, 'form' > { @@ -328,7 +324,7 @@ export interface ReactFormGroupProps< TGroupName, TGroupValue, ToFormGroupErrorTypes>, - TFormErrorTypes, + TFormError, TFieldComponents >, ) => ReactNode @@ -336,7 +332,7 @@ export interface ReactFormGroupProps< export interface ReactFormGroupComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends ReactComponentTree, > { < @@ -349,7 +345,7 @@ export interface ReactFormGroupComponent< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes, + TFormError, TFieldComponents >, ): ReactNode @@ -357,25 +353,21 @@ export interface ReactFormGroupComponent< export interface ReactTanStackFormComponents< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends ReactComponentTree, > { /** * TODO docs */ - Field: ReactFormFieldComponent + Field: ReactFormFieldComponent ArrayField: ReactFormArrayFieldComponent< TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > - Subscribe: ReactFormSubscribeComponent + Subscribe: ReactFormSubscribeComponent - FormGroup: ReactFormGroupComponent< - TFormData, - TFormErrorTypes, - TFieldComponents - > + FormGroup: ReactFormGroupComponent } diff --git a/packages/react-form/src/ReactForm/ReactFormApi.lib.tsx b/packages/react-form/src/ReactForm/ReactFormApi.lib.tsx index c3e6375c4d..1a7e7acb9f 100644 --- a/packages/react-form/src/ReactForm/ReactFormApi.lib.tsx +++ b/packages/react-form/src/ReactForm/ReactFormApi.lib.tsx @@ -7,14 +7,14 @@ import { createFormGroupComponent, createSubscribeComponent, } from './Components.lib' -import type { DefaultOptions, FormOptions } from '@tanstack/form-core' +import type { AnyFormOptions, DefaultOptions } from '@tanstack/form-core' import type { ReactTanStackFormComponents } from './Components.public' const useReactId = (React as typeof React & { useId?: () => string }).useId ?? (() => undefined) export class InternalReactFormApi - extends InternalFormApi + extends InternalFormApi implements ReactTanStackFormComponents { Field: ReactTanStackFormComponents['Field'] @@ -22,10 +22,7 @@ export class InternalReactFormApi Subscribe: ReactTanStackFormComponents['Subscribe'] FormGroup: ReactTanStackFormComponents['FormGroup'] - constructor( - options: FormOptions, - defaultOptions?: DefaultOptions, - ) { + constructor(options: AnyFormOptions, defaultOptions?: DefaultOptions) { super(options, defaultOptions) this.Field = createFieldComponent( this, @@ -39,17 +36,13 @@ export class InternalReactFormApi } } -export function initializeForm( - options: FormOptions, -): InternalReactFormApi { +export function initializeForm(options: AnyFormOptions): InternalReactFormApi { return new InternalReactFormApi(options) } export function useInternalForm( - options: FormOptions, - initializeFn: ( - options: FormOptions, - ) => InternalReactFormApi, + options: AnyFormOptions, + initializeFn: (options: AnyFormOptions) => InternalReactFormApi, ) { const reactFormId = useReactId() const resolvedOptions = diff --git a/packages/react-form/src/ReactForm/formApiTypes.public.ts b/packages/react-form/src/ReactForm/formApiTypes.public.ts index 5bc632094a..6d5339b3ca 100644 --- a/packages/react-form/src/ReactForm/formApiTypes.public.ts +++ b/packages/react-form/src/ReactForm/formApiTypes.public.ts @@ -1,4 +1,9 @@ -import type { AnyFormApi, FormApi, FormErrorTypes } from '@tanstack/form-core' +import type { + AnyFormApi, + FormApi, + FormErrorTypes, + ScopedErrorTypes, +} from '@tanstack/form-core' import type { ReactTanStackFormComponents } from './Components.public' import type { AnyReactFormComponentMap, @@ -7,22 +12,18 @@ import type { type ExtendedFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends ReactComponentTree, -> = FormApi & - ReactTanStackFormComponents +> = FormApi & + ReactTanStackFormComponents export type ReactFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TComponents extends AnyReactFormComponentMap, > = unknown extends TComponents['formComponents'] - ? ExtendedFormApi - : ExtendedFormApi< - TFormData, - TFormErrorTypes, - TComponents['fieldComponents'] - > & + ? ExtendedFormApi + : ExtendedFormApi & TComponents['formComponents'] /** diff --git a/packages/react-form/src/ReactForm/formType.public.ts b/packages/react-form/src/ReactForm/formType.public.ts index a02f7c4d0a..dfb8f81ddc 100644 --- a/packages/react-form/src/ReactForm/formType.public.ts +++ b/packages/react-form/src/ReactForm/formType.public.ts @@ -1,7 +1,7 @@ import type { AnyFormOptions, FormOptions, - FormValidators, + GetFormErrorTypes, ToFormErrorTypes, } from '@tanstack/form-core' import type { @@ -16,12 +16,10 @@ import type { ReactFormApi } from './formApiTypes.public' // early can break passing forms as props to child components. // This must remain `any` so that `form={form}` remains working. There are type tests for it, so feel // free to tinker. -type ReactFormTypeErrorTypes< - TFormValidators extends FormValidators, - TSubmitReturn, -> = unknown extends TSubmitReturn - ? any - : ToFormErrorTypes +type ReactFormTypeErrorTypes = + unknown extends TSubmitReturn + ? any + : GetFormErrorTypes /** * Derives the React form API type represented by a reusable options object. @@ -55,6 +53,7 @@ type ReactFormTypeErrorTypes< export type ReactFormType = TOptions extends FormOptions< infer TFormData, + any, infer TFormValidators, infer TSubmitReturn, infer TComponents diff --git a/packages/react-form/src/ReactForm/useField.lib.ts b/packages/react-form/src/ReactForm/useField.lib.ts index ddb5dbb0a7..eafd862598 100644 --- a/packages/react-form/src/ReactForm/useField.lib.ts +++ b/packages/react-form/src/ReactForm/useField.lib.ts @@ -17,7 +17,7 @@ interface InternalFieldProps extends ReactFormFieldProps< any, any > { - form: InternalFormApi + form: InternalFormApi } export function useField( diff --git a/packages/react-form/src/ReactForm/useForm.public.ts b/packages/react-form/src/ReactForm/useForm.public.ts index bd0adefd85..15357d93d1 100644 --- a/packages/react-form/src/ReactForm/useForm.public.ts +++ b/packages/react-form/src/ReactForm/useForm.public.ts @@ -1,7 +1,8 @@ import { initializeForm, useInternalForm } from './ReactFormApi.lib' import type { + AnyFormOptions, FormOptions, - FormValidators, + GetFormErrorTypes, ToFormErrorTypes, } from '@tanstack/form-core' import type { @@ -12,17 +13,24 @@ import type { ReactFormApi } from './formApiTypes.public' export type UseFormHook = < TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( - options: FormOptions, + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, ) => ReactFormApi< TFormData, - ToFormErrorTypes, + GetFormErrorTypes, TComponents > -function useFormHook(options: FormOptions) { +function useFormHook(options: AnyFormOptions) { const form = useInternalForm(options, initializeForm) return form } diff --git a/packages/react-form/tests/createFormHook.spec.tsx b/packages/react-form/tests/createFormHook.spec.tsx index 3a4dabf83a..eac41cd19f 100644 --- a/packages/react-form/tests/createFormHook.spec.tsx +++ b/packages/react-form/tests/createFormHook.spec.tsx @@ -21,8 +21,7 @@ describe('createFormHook', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: () => ({ fields: { name: 'Name is required' } }), }, ], diff --git a/packages/react-form/tests/useForm.spec.tsx b/packages/react-form/tests/useForm.spec.tsx index 83587f5a26..1206217a4c 100644 --- a/packages/react-form/tests/useForm.spec.tsx +++ b/packages/react-form/tests/useForm.spec.tsx @@ -222,7 +222,7 @@ describe('useForm', () => { expect(fieldB).toHaveValue('new-b') }) - it('does not render with pre-validation state for synchronous runOnMount validation', async () => { + it('does not render with pre-validation state for synchronous mount validation', async () => { const renderStates: Array<{ errors: Array isValid: boolean @@ -234,8 +234,7 @@ describe('useForm', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: () => 'Name is required', }, ], @@ -311,7 +310,7 @@ describe('useForm', () => { expect(screen.getByTestId('is-default-value')).toHaveTextContent('true') }) - it('does not crash when asynchronous runOnMount validation resolves after unmount', async () => { + it('does not crash when asynchronous mount validation resolves after unmount', async () => { let resolveValidation!: (value: string) => void const validator = vi.fn( () => @@ -325,8 +324,7 @@ describe('useForm', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: validator, }, ], diff --git a/packages/vue-form/tests/adapter.spec.tsx b/packages/vue-form/tests/adapter.spec.tsx index c911fb3389..f105ef3331 100644 --- a/packages/vue-form/tests/adapter.spec.tsx +++ b/packages/vue-form/tests/adapter.spec.tsx @@ -124,8 +124,7 @@ describe('Vue adapter parity', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: () => 'Name is required', }, ], From 0b2ee53e39934f0dcfc59956f46531f767d39d97 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:19:35 +0200 Subject: [PATCH 22/43] perf: reduce instantiations by half --- .../src/FormApi/formOptions.public.ts | 6 +- .../src/validation/formValidators.public.ts | 100 ++++++++++++------ .../FormApi/formOptions.public.test-d.ts | 72 +++++++++++++ 3 files changed, 143 insertions(+), 35 deletions(-) diff --git a/packages/form-core/src/FormApi/formOptions.public.ts b/packages/form-core/src/FormApi/formOptions.public.ts index bedf0216d3..3e3f73ca22 100644 --- a/packages/form-core/src/FormApi/formOptions.public.ts +++ b/packages/form-core/src/FormApi/formOptions.public.ts @@ -129,7 +129,11 @@ export interface FormOptions< * ``` */ validators?: TValidators & - FormOptionsValidators, TValidatorConfig> + FormOptionsValidators< + NoInfer, + TValidatorConfig, + NoInfer + > /** * Callbacks for change, blur, submit, mount, and reset events. * diff --git a/packages/form-core/src/validation/formValidators.public.ts b/packages/form-core/src/validation/formValidators.public.ts index 1faf798859..3ba7569c08 100644 --- a/packages/form-core/src/validation/formValidators.public.ts +++ b/packages/form-core/src/validation/formValidators.public.ts @@ -430,6 +430,54 @@ type GetValidatorEvents = type SkipOnSubmitConfig = (TValidatorConfig & boolean) | ValidatorPredicateFn +interface FormValidatorConfig< + TFormData, + TValidatorConfig, +> extends BaseFormValidator { + /** + * Whether to skip this validator during a client submission attempt, + * independently of its configured triggers. + * + * Pass `true` to skip it, or a synchronous callback that returns `true` to + * skip it for the current attempt. `false` or an omitted option allows + * submission validation. Configured triggers still use their own conditions. + * + * A skipped validator's entry in submission `validatorOutputs` is `undefined`. + * + * @example + * ```ts + * const isDraft = false + * + * formOptions({ + * defaultValues: { email: '' }, + * validators: [ + * { + * skipOnSubmit: isDraft, + * run: ({ value }) => { + * if (!value.email.includes('@)) { + * return 'Enter a valid email' + * } + * } + * }, + * ], + * }) + * ``` + */ + skipOnSubmit?: SkipOnSubmitConfig +} + +// Intersecting a schema with StandardSchemaV1 can rebind its polymorphic +// `this` and recursively compare its methods. Keep compatible schemas intact. +type FormOptionsValidatorRunner< + TFormData, + TEvents extends ValidatorEvents, + TValidator, +> = TValidator extends { + run: infer TRun extends StandardSchemaV1 +} + ? { run: TRun } + : FormValidatorRunner + /** * Validates the complete form values with a function or Standard Schema. * @@ -467,42 +515,11 @@ type SkipOnSubmitConfig = */ export interface FormValidator extends - BaseFormValidator, + FormValidatorConfig, FormValidatorRunner< TFormData, NoInfer> - > { - /** - * Whether to skip this validator during a client submission attempt, - * independently of its configured triggers. - * - * Pass `true` to skip it, or a synchronous callback that returns `true` to - * skip it for the current attempt. `false` or an omitted option allows - * submission validation. Configured triggers still use their own conditions. - * - * A skipped validator's entry in submission `validatorOutputs` is `undefined`. - * - * @example - * ```ts - * const isDraft = false - * - * formOptions({ - * defaultValues: { email: '' }, - * validators: [ - * { - * skipOnSubmit: isDraft, - * run: ({ value }) => { - * if (!value.email.includes('@)) { - * return 'Enter a valid email' - * } - * } - * }, - * ], - * }) - * ``` - */ - skipOnSubmit?: SkipOnSubmitConfig -} + > {} /** * Matches each form validator to its own trigger and submission settings so @@ -516,8 +533,23 @@ export interface FormValidator export type FormOptionsValidators< TFormData, TValidatorConfig extends unknown[], + TValidators = unknown, > = { // Infer trigger names and the skip condition together before contextualizing run. // Extract keeps skip conditions and unknown (omitted options) out of events. - [K in keyof TValidatorConfig]: FormValidator + [K in keyof TValidatorConfig]: FormValidatorConfig< + TFormData, + TValidatorConfig[K] + > & + // Generic arrays need the broad runner for assignment in generic helpers. + (number extends TValidatorConfig['length'] + ? FormValidatorRunner< + TFormData, + NoInfer> + > + : FormOptionsValidatorRunner< + TFormData, + NoInfer>, + K extends keyof TValidators ? TValidators[K] : unknown + >) } diff --git a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts index 2ce246c564..ce1dbb0bc9 100644 --- a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -171,6 +171,78 @@ describe('formOptions.strictSchema', () => { }) }) + it('preserves schema outputs and callback events when reusing mixed validators', () => { + const schema = z.object({ + guest: z.object({ name: z.string(), age: z.coerce.number() }), + }) + type Input = z.input + type Output = z.output + + const options = formOptions.strictSchema(schema, { + defaultValues: { guest: { name: '', age: '' } }, + validators: [ + { + triggers: [ + 'blur', + { + trigger: 'change', + when: ({ value, fieldApi }) => { + expectTypeOf(value).toEqualTypeOf() + return fieldApi !== undefined && fieldApi.meta.isInvalid + }, + }, + ], + run: schema, + }, + { + triggers: ['server'], + skipOnSubmit: true, + run: ({ value, event, createOutput }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(event).toEqualTypeOf<'server'>() + return createOutput(value.guest.name.length) + }, + }, + ], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf<[Output, undefined]>() + }, + }) + + formOptions({ + ...options, + onSubmit: ({ value, validatorOutputs }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(validatorOutputs[0]).toEqualTypeOf() + }, + }) + }) + + it('checks schema inputs and trigger settings for schema runners', () => { + const schema = z.object({ name: z.string() }) + + formOptions.strictSchema(schema, { + defaultValues: { name: '' }, + validators: [ + { + // @ts-expect-error The validator schema input must match the form. + run: z.object({ name: z.number() }), + }, + ], + }) + + formOptions.strictSchema(schema, { + defaultValues: { name: '' }, + validators: [ + { + // @ts-expect-error Submit is controlled by skipOnSubmit. + triggers: ['submit'], + run: schema, + }, + ], + }) + }) + it('types listeners and error visibility from schema input without validators', () => { const schema = z.object({ status: z.enum(['draft', 'published']), From d51cd0231b421ed5e1f61e145d599d97f906972d Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:06:06 +0200 Subject: [PATCH 23/43] fix createValidator usage --- .../src/app/booking/shared-form.tsx | 15 +-- .../src/validation/formValidators.public.ts | 82 +++++++++++------ .../FormApi/formOptions.public.test-d.ts | 91 +++++++++++++++++-- 3 files changed, 138 insertions(+), 50 deletions(-) diff --git a/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx b/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx index f7b86784c4..fa6107da4d 100644 --- a/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx +++ b/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx @@ -28,7 +28,6 @@ export function StepForm(props: StepFormProps) { ) } -// TODO bring back this config for forms? export const rewardEarlyPunishLate = createValidator({ triggers: [ 'blur', @@ -44,19 +43,7 @@ export const bookingFormOptions = appFormOptions.strictSchema( { errorVisibility: ({ state, fieldState }) => fieldState.meta.isBlurred || state.submissionAttempts > 0, - validators: [ - { - triggers: [ - 'blur', - { - trigger: 'change', - when: ({ fieldApi }) => - fieldApi !== undefined && fieldApi.meta.isInvalid, - }, - ], - run: bookingFormSchema, - }, - ], + validators: [rewardEarlyPunishLate(bookingFormSchema)], defaultValues: { guestDetails: { name: '', diff --git a/packages/form-core/src/validation/formValidators.public.ts b/packages/form-core/src/validation/formValidators.public.ts index 3ba7569c08..017101c77c 100644 --- a/packages/form-core/src/validation/formValidators.public.ts +++ b/packages/form-core/src/validation/formValidators.public.ts @@ -225,6 +225,11 @@ export type FormValidatorFn = ( * @typeParam TValue - Library-managed. Do not specify explicitly. */ export interface ValidatorPredicateContext { + /** + * Identifies a form validator in predicates shared with field or form group + * validators. Remains `'form'` when a field change or blur triggers validation. + */ + scope: 'form' /** The owning form calling this function. */ formApi: FormApi /** @@ -466,18 +471,6 @@ interface FormValidatorConfig< skipOnSubmit?: SkipOnSubmitConfig } -// Intersecting a schema with StandardSchemaV1 can rebind its polymorphic -// `this` and recursively compare its methods. Keep compatible schemas intact. -type FormOptionsValidatorRunner< - TFormData, - TEvents extends ValidatorEvents, - TValidator, -> = TValidator extends { - run: infer TRun extends StandardSchemaV1 -} - ? { run: TRun } - : FormValidatorRunner - /** * Validates the complete form values with a function or Standard Schema. * @@ -521,14 +514,54 @@ export interface FormValidator NoInfer> > {} +// I know, looks pointless. The main problem we're tackling here is +// that `run` can be a standard schema, which could be anything from +// zod to valibot and all that stuff. Those types are HUGE. +// We have to avoid TypeScript trying to make an intersection of +// typeof schema & StandardSchema. This workaround instead does +// typeof schema & typeof schema. + +// Want to change these? Verify before/after in examples/react/ui-integration-shadcn. +// It has a large schema to test. +type FormOptionsValidatorRunner< + TFormData, + TEvents extends ValidatorEvents, + TValidator, +> = TValidator extends { + run: infer TRun extends StandardSchemaV1 +} + ? { run: TRun } + : FormValidatorRunner + +type GetValidatorRunner< + TFormData, + TValidatorConfig extends unknown[], + TValidators, + K extends keyof TValidatorConfig, +> = number extends TValidatorConfig['length'] + ? FormValidatorRunner< + TFormData, + NoInfer> + > + : FormOptionsValidatorRunner< + TFormData, + NoInfer>, + K extends keyof TValidators ? TValidators[K] : unknown + > + +type FormOptionsValidator< + TFormData, + TValidatorConfig extends unknown[], + TValidators, + K extends keyof TValidatorConfig, +> = FormValidatorConfig & + GetValidatorRunner + /** * Matches each form validator to its own trigger and submission settings so * callback values and events are inferred for that validator. * * Validator array positions are preserved. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TValidatorConfig - Library-managed. Do not specify explicitly. */ export type FormOptionsValidators< TFormData, @@ -537,19 +570,10 @@ export type FormOptionsValidators< > = { // Infer trigger names and the skip condition together before contextualizing run. // Extract keeps skip conditions and unknown (omitted options) out of events. - [K in keyof TValidatorConfig]: FormValidatorConfig< + [K in keyof TValidatorConfig]: FormOptionsValidator< TFormData, - TValidatorConfig[K] - > & - // Generic arrays need the broad runner for assignment in generic helpers. - (number extends TValidatorConfig['length'] - ? FormValidatorRunner< - TFormData, - NoInfer> - > - : FormOptionsValidatorRunner< - TFormData, - NoInfer>, - K extends keyof TValidators ? TValidators[K] : unknown - >) + TValidatorConfig, + TValidators, + K + > } diff --git a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts index ce1dbb0bc9..fcb8c7401f 100644 --- a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -1,17 +1,11 @@ import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { formOptions } from '../../src/FormApi/formOptions.public' +import { createValidator, createValidators } from '../../src/validation.public' import type { AnyFieldApi } from '../../src/FieldApi/FieldApi.public' -import type { FormApi } from '../../src/FormApi/FormApi.public' import type { OnSubmitError } from '../../src/FormApi/formSubmit.public' -import type { - FormOptions, - FormOptionsApi, -} from '../../src/FormApi/formOptions.public' import type { ServerFormState } from '../../src/FormApi/formServerState.public' import type { StandardSchemaV1Issue } from '../../src/standardSchema.public' -// TODO finalize migration -import type { FormErrorTypes } from '../../src/validation.public' import type { CreateErrorMapFn, ParseIssuesFn, @@ -20,6 +14,89 @@ import type { } from '../../src/validation/state.public' describe('formOptions.strictSchema', () => { + it('accepts reusable scope-aware predicates with schema runners', () => { + const rewardEarlyPunishLate = createValidator({ + triggers: [ + 'blur', + { + trigger: 'change', + when: ({ fieldApi }) => + fieldApi !== undefined && fieldApi.meta.isInvalid, + }, + ], + skipOnSubmit: ({ scope, formApi }) => + scope === 'form' && formApi.state.submissionAttempts === 0, + }) + const schema = z.object({ + name: z.string().transform((name) => name.length), + }) + + formOptions.strictSchema(schema, { + defaultValues: { name: '' }, + validators: [rewardEarlyPunishLate(schema)], + onSubmit: ({ value, validatorOutputs }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(validatorOutputs).toEqualTypeOf< + [{ name: number } | undefined] + >() + }, + }) + }) + + it('accepts reusable validator arrays with scope-aware predicates', () => { + const withChecks = createValidators([ + { + triggers: [ + { + trigger: 'change', + when: ({ scope, fieldApi }) => + scope === 'form' && fieldApi !== undefined, + }, + ], + }, + { + triggers: ['blur'], + skipOnSubmit: ({ scope }) => scope !== 'form', + }, + ]) + const schema = z.object({ name: z.string() }) + + formOptions.strictSchema(schema, { + defaultValues: { name: '' }, + validators: [...withChecks(schema, schema)], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< + [{ name: string }, { name: string } | undefined] + >() + }, + }) + }) + + it('exposes the form scope to inline predicates', () => { + formOptions.strictSchema(z.object({ name: z.string() }), { + defaultValues: { name: '' }, + validators: [ + { + triggers: [ + { + trigger: 'change', + when: ({ scope, value }) => { + expectTypeOf(scope).toEqualTypeOf<'form'>() + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + return value.name !== '' + }, + }, + ], + skipOnSubmit: ({ scope }) => { + expectTypeOf(scope).toEqualTypeOf<'form'>() + return false + }, + run: () => undefined, + }, + ], + }) + }) + it('uses the complete schema input without registering a validator', () => { const schema = z.object({ status: z.enum(['draft', 'published']), From 035cca9f84d49c9beae1331739152d3ffbb94322 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:30:55 +0200 Subject: [PATCH 24/43] fix: fix reuse of form options --- .../src/FormApi/formOptions.public.ts | 13 +++-- .../FormApi/formOptions.public.test-d.ts | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/form-core/src/FormApi/formOptions.public.ts b/packages/form-core/src/FormApi/formOptions.public.ts index 3e3f73ca22..c252371019 100644 --- a/packages/form-core/src/FormApi/formOptions.public.ts +++ b/packages/form-core/src/FormApi/formOptions.public.ts @@ -21,6 +21,11 @@ type TryGetValidatorOutputs = ? GetValidatorOutputs : [] +// Spreading `formOptions` results in things like `useForm` breaks when no validator was +// provided. We have to default to [] to make it work properly. +type InferredValidatorConfig = + TValidators extends ReadonlyArray ? TValidatorConfig : [] + /** * Configures a form's initial values, validation, listeners, and submission. * @@ -245,7 +250,7 @@ export type FormOptionsStrictSchemaFn = < >, ) => FormOptions< NoInfer>, - TValidatorConfig, + InferredValidatorConfig, TValidators, TSubmitReturn, TPayload @@ -299,7 +304,7 @@ export type FormOptionsLooseSchemaFn = < >, ) => FormOptions< InferUnion>, - TValidatorConfig, + InferredValidatorConfig, TValidators, TSubmitReturn, TPayload @@ -335,7 +340,7 @@ export interface FormOptionsApi { >, ): FormOptions< TFormData, - TValidatorConfig, + InferredValidatorConfig, TValidators, TSubmitReturn, TPayload @@ -402,7 +407,7 @@ export interface FormOptionsApi { */ export const formOptions = ((opts) => opts) as FormOptionsApi -formOptions.strictSchema = (_schema, opts) => opts +formOptions.strictSchema = (_schema, opts) => opts as never formOptions.looseSchema = (_schema, opts) => opts as never /** diff --git a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts index fcb8c7401f..658d9263ca 100644 --- a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -13,6 +13,55 @@ import type { ValidationFailureItem, } from '../../src/validation/state.public' +describe('formOptions reuse', () => { + it('keeps omitted validators empty when adding a submit handler', () => { + const sharedOptions = formOptions({ defaultValues: { name: '' } }) + + formOptions({ + ...sharedOptions, + onSubmit: ({ value, validatorOutputs, formApi }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(validatorOutputs).toEqualTypeOf<[]>() + expectTypeOf(formApi.state.errors).toEqualTypeOf() + }, + }) + }) + + it('reuses strict schema options without registering validators', () => { + const schema = z.object({ status: z.enum(['draft', 'published']) }) + const sharedOptions = formOptions.strictSchema(schema, { + defaultValues: { status: 'draft' }, + }) + + formOptions({ + ...sharedOptions, + onSubmit: ({ value, validatorOutputs, formApi }) => { + expectTypeOf(value).toEqualTypeOf>() + expectTypeOf(validatorOutputs).toEqualTypeOf<[]>() + expectTypeOf(formApi.state.errors).toEqualTypeOf() + }, + }) + }) + + it('reuses loose schema options without registering validators', () => { + const sharedOptions = formOptions.looseSchema( + z.object({ name: z.string() }), + { + defaultValues: { name: null }, + }, + ) + + formOptions({ + ...sharedOptions, + onSubmit: ({ value, validatorOutputs, formApi }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string | null }>() + expectTypeOf(validatorOutputs).toEqualTypeOf<[]>() + expectTypeOf(formApi.state.errors).toEqualTypeOf() + }, + }) + }) +}) + describe('formOptions.strictSchema', () => { it('accepts reusable scope-aware predicates with schema runners', () => { const rewardEarlyPunishLate = createValidator({ From 6316368b230a25bd3fb66a80f679960b757b9559 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:05:37 +0200 Subject: [PATCH 25/43] fix: allow proper autocomplete of triggers --- examples/react/basic/src/index.tsx | 4 ++-- .../src/validation/formValidators.public.ts | 16 +++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/examples/react/basic/src/index.tsx b/examples/react/basic/src/index.tsx index a045cb10d8..573eb927da 100644 --- a/examples/react/basic/src/index.tsx +++ b/examples/react/basic/src/index.tsx @@ -22,12 +22,12 @@ function App() { firstName: '', lastName: '', }, - onSubmit: ({ value, createValidationError }) => { + onSubmit: ({ value, createErrorMap }) => { // Do something with form data console.log(value) // If your endpoint returned validation errors, pass them on - return createValidationError({ + return createErrorMap({ fields: { // Set field-specific errors from the form! firstName: 'Name already exists', diff --git a/packages/form-core/src/validation/formValidators.public.ts b/packages/form-core/src/validation/formValidators.public.ts index 017101c77c..44432e54c0 100644 --- a/packages/form-core/src/validation/formValidators.public.ts +++ b/packages/form-core/src/validation/formValidators.public.ts @@ -379,7 +379,7 @@ type TriggerConfig = * @typeParam TFormData - Library-managed. Do not specify explicitly. * @typeParam TFormTriggers - Library-managed. Do not specify explicitly. */ -interface BaseFormValidator { +interface BaseFormValidator { /** * Events that run this validator in addition to submission, optionally with * a `when` condition. @@ -407,7 +407,12 @@ interface BaseFormValidator { * ] * ``` */ - triggers?: TriggerConfig[] + triggers?: + | TriggerConfig[] + // Autocomplete would wrongly assume that TFormTriggers is the only thing + // you can write into the array. We keep a separate type there so that + // autocomplete actually suggests the whole possible list. + | TriggerConfig[] /** * How long change and blur validation waits after the latest matching event * before it runs. @@ -438,7 +443,8 @@ type SkipOnSubmitConfig = interface FormValidatorConfig< TFormData, TValidatorConfig, -> extends BaseFormValidator { + TAvailableTriggers, +> extends BaseFormValidator { /** * Whether to skip this validator during a client submission attempt, * independently of its configured triggers. @@ -508,7 +514,7 @@ interface FormValidatorConfig< */ export interface FormValidator extends - FormValidatorConfig, + FormValidatorConfig, FormValidatorRunner< TFormData, NoInfer> @@ -554,7 +560,7 @@ type FormOptionsValidator< TValidatorConfig extends unknown[], TValidators, K extends keyof TValidatorConfig, -> = FormValidatorConfig & +> = FormValidatorConfig & GetValidatorRunner /** From 7aa3e9a0b68f4877341a52a4c07b4eb7b0f1ff9c Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:56:33 +0200 Subject: [PATCH 26/43] fix: fix lit-form --- packages/lit-form/src/get-form-type.ts | 26 +++-- .../lit-form/src/tanstack-form-controller.ts | 103 +++++++++--------- packages/lit-form/src/with-fields.ts | 12 +- .../lit-form/tests/form-controller.spec.ts | 2 +- 4 files changed, 76 insertions(+), 67 deletions(-) diff --git a/packages/lit-form/src/get-form-type.ts b/packages/lit-form/src/get-form-type.ts index 471c74ee89..7528f930eb 100644 --- a/packages/lit-form/src/get-form-type.ts +++ b/packages/lit-form/src/get-form-type.ts @@ -1,9 +1,14 @@ -import type { AnyFormOptions, FormOptions } from '@tanstack/form-core' +import type { + AnyFormOptions, + FormOptions, + GetFormErrorTypes, +} from '@tanstack/form-core' import type { TanStackFormController } from './tanstack-form-controller.js' -type LitFormTypeSubmitReturn = unknown extends TSubmitReturn - ? any - : TSubmitReturn +type LitFormTypeErrorTypes = + unknown extends TSubmitReturn + ? any + : GetFormErrorTypes /** * A Lit form controller whose form data, validator, and submit types are @@ -28,7 +33,7 @@ type LitFormTypeSubmitReturn = unknown extends TSubmitReturn * } * ``` */ -export type AnyLitFormApi = TanStackFormController +export type AnyLitFormApi = TanStackFormController /** * Derives the Lit form controller type represented by a reusable options @@ -58,14 +63,17 @@ export type AnyLitFormApi = TanStackFormController export type LitFormType = TOptions extends FormOptions< infer TFormData, - infer TFormValidators, + infer TValidatorConfig, + infer TValidators, infer TSubmitReturn, - unknown + any > ? TanStackFormController< TFormData, - TFormValidators, - LitFormTypeSubmitReturn + TValidatorConfig, + TValidators, + TSubmitReturn, + LitFormTypeErrorTypes > : never diff --git a/packages/lit-form/src/tanstack-form-controller.ts b/packages/lit-form/src/tanstack-form-controller.ts index fd6910b8a3..eb8bd737e6 100644 --- a/packages/lit-form/src/tanstack-form-controller.ts +++ b/packages/lit-form/src/tanstack-form-controller.ts @@ -14,16 +14,15 @@ import type { FieldApiOptions, FieldValidators, FormApi, - FormErrorTypes, FormGroupApi, FormGroupOptions, FormGroupState, FormGroupValidators, FormOptions, FormState, - FormValidators, + GetFormErrorTypes, + ScopedErrorTypes, ToFieldError, - ToFormErrorTypes, ToFormGroupErrorTypes, } from '@tanstack/form-core' import type { @@ -51,7 +50,7 @@ type LitFieldOptions< TFieldValidators extends FieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > = FieldApiOptions< TFieldData, TFieldName, @@ -59,7 +58,7 @@ type LitFieldOptions< TFieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes + TFormError > type LitFieldRenderApi< @@ -69,20 +68,20 @@ type LitFieldRenderApi< TFieldValidators extends FieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > = FieldApi< TFieldName, TFieldValue, - ToFieldError, TGroupFieldError, TFormErrorTypes>, + ToFieldError, TGroupFieldError, TFormError>, TFormData, - TFormErrorTypes + TFormError > export interface LitFieldMethods< TFieldData, TGroupFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > { field< const TFieldName extends DeepKeys, @@ -99,7 +98,7 @@ export interface LitFieldMethods< TFieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes + TFormError >, render: RenderCallback< LitFieldRenderApi< @@ -109,7 +108,7 @@ export interface LitFieldMethods< TFieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes + TFormError > >, ): unknown @@ -129,7 +128,7 @@ export interface LitFieldMethods< TFieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes + TFormError >, render: RenderCallback< LitFieldRenderApi< @@ -139,7 +138,7 @@ export interface LitFieldMethods< TFieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes + TFormError > >, ): unknown @@ -157,22 +156,16 @@ export type LitFormGroupApi< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes extends FormErrorTypes, - TFormErrorTypes extends FormErrorTypes, -> = FormGroupApi< - TFormData, - TGroupName, - TGroupValue, - TGroupErrorTypes, - TFormErrorTypes -> & + TGroupError extends ScopedErrorTypes, + TFormError extends ScopedErrorTypes, +> = FormGroupApi & LitFieldMethods< TGroupValue, - TGroupErrorTypes['fieldError'], + TGroupError['subfieldError'], TFormData, - TFormErrorTypes + TFormError > & - LitSubscribeMethod> + LitSubscribeMethod> /** * Owns a form for a Lit reactive-controller host and provides Lit-specific @@ -208,28 +201,23 @@ export type LitFormGroupApi< */ export class TanStackFormController< TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, + TFormError extends ScopedErrorTypes = GetFormErrorTypes< + TValidators, + TSubmitReturn + >, > implements ReactiveController, - LitFieldMethods< - TFormData, - never, - TFormData, - ToFormErrorTypes - >, - LitSubscribeMethod< - FormState> - > + LitFieldMethods, + LitSubscribeMethod> { - readonly #form: InternalFormApi + readonly #form: InternalFormApi #unmount?: () => void - get api(): FormApi< - TFormData, - ToFormErrorTypes - > { + get api(): FormApi { return this.#form } @@ -240,7 +228,13 @@ export class TanStackFormController< */ constructor( host: ReactiveControllerHost, - options: FormOptions, + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, ) { this.#form = new InternalFormApi(options) new TanStackStoreSelector(host, () => this.#form._atoms.resetVersion) @@ -263,7 +257,13 @@ export class TanStackFormController< /** Updates reactive form options without replacing the form instance. */ update( - options: FormOptions, + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, ) { this.#form._update(options) } @@ -283,7 +283,7 @@ export class TanStackFormController< TFieldValidators, never, TFormData, - ToFormErrorTypes + TFormError >, render: RenderCallback< LitFieldRenderApi< @@ -293,7 +293,7 @@ export class TanStackFormController< TFieldValidators, never, TFormData, - ToFormErrorTypes + TFormError > >, ): unknown { @@ -320,7 +320,7 @@ export class TanStackFormController< TFieldValidators, never, TFormData, - ToFormErrorTypes + TFormError >, render: RenderCallback< LitFieldRenderApi< @@ -330,7 +330,7 @@ export class TanStackFormController< TFieldValidators, never, TFormData, - ToFormErrorTypes + TFormError > >, ): unknown { @@ -343,12 +343,7 @@ export class TanStackFormController< } subscribe( - selector: ( - state: FormState< - TFormData, - ToFormErrorTypes - >, - ) => TSelected, + selector: (state: FormState) => TSelected, render: RenderCallback>, when?: (selected: NoInfer) => boolean, ): unknown { @@ -366,7 +361,7 @@ export class TanStackFormController< TGroupName, TGroupValue, TGroupValidators, - ToFormErrorTypes + TFormError >, 'form' >, @@ -376,7 +371,7 @@ export class TanStackFormController< TGroupName, TGroupValue, ToFormGroupErrorTypes, - ToFormErrorTypes + TFormError > >, ): unknown { diff --git a/packages/lit-form/src/with-fields.ts b/packages/lit-form/src/with-fields.ts index 318f495380..51f0893ebf 100644 --- a/packages/lit-form/src/with-fields.ts +++ b/packages/lit-form/src/with-fields.ts @@ -14,7 +14,7 @@ import type { FieldGroupHelper, FormApiArrayMethods, FormApiFieldMethods, - FormErrorTypes, + ScopedErrorTypes, ValidationIssue, } from '@tanstack/form-core' import type { ReadonlyAtom } from '@tanstack/lit-store' @@ -32,7 +32,7 @@ export type LitFieldGroupApi = LitFieldMethods< TFieldData, ValidationIssue, unknown, - FormErrorTypes + ScopedErrorTypes > & LitSubscribeMethod & FormApiFieldMethods & @@ -57,7 +57,13 @@ export type FieldGroupFieldBindingsOf = ? FieldGroupFieldBindings, TFormData> : never -type AnyLitForm = TanStackFormController +type AnyLitForm = TanStackFormController< + TFormData, + any, + any, + any, + any +> export type FieldGroupWithFieldsFn< TFieldGroup extends { diff --git a/packages/lit-form/tests/form-controller.spec.ts b/packages/lit-form/tests/form-controller.spec.ts index 751c769a82..ce716695c4 100644 --- a/packages/lit-form/tests/form-controller.spec.ts +++ b/packages/lit-form/tests/form-controller.spec.ts @@ -21,7 +21,7 @@ const personFormOptions = formOptions({ }) class TestFormElement extends LitElement { - submit = vi.fn() + submit = vi.fn<(value: typeof personFormOptions.defaultValues) => void>() formMount = vi.fn() fieldMount = vi.fn() fieldUnmount = vi.fn() From 5405da6b9783f5da6c55bda8eff667ed43b1b392 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:18:15 +0200 Subject: [PATCH 27/43] fix: NGL, I got sidetracked. Various fixes --- .../form-core/src/FieldApi/FieldApi.public.ts | 5 +- packages/form-core/src/FormApi/FormApi.lib.ts | 1 - .../src/FormApi/errorVisibility.public.ts | 5 +- .../src/FormApi/formListeners.public.ts | 5 +- .../src/FormApi/formOptions.public.ts | 5 +- .../src/FormApi/formSubmit.public.ts | 35 +- .../form-core/src/FormApi/handleSubmit.lib.ts | 6 +- .../src/FormGroupApi/FormGroupApi.lib.ts | 10 +- .../form-core/src/ValidatorInstance.lib.ts | 15 +- packages/form-core/src/ssr.lib.ts | 7 +- packages/form-core/src/standardSchema.lib.ts | 69 ++-- packages/form-core/src/validation.public.ts | 37 +- .../src/validation/createErrorMap.lib.ts | 53 ++- .../form-core/src/validation/errors.lib.ts | 2 +- .../form-core/src/validation/execution.lib.ts | 6 +- .../form-core/src/validation/mount.lib.ts | 49 ++- .../src/validation/parseIssues.lib.ts | 29 +- .../form-core/src/validation/pipeline.lib.ts | 21 +- .../form-core/src/validation/state.public.ts | 91 ++--- .../tests/FormApi/FormApi.public.test-d.ts | 7 - .../FormApi/formOptions.public.test-d.ts | 29 +- .../tests/FormApi/handleSubmit.lib.spec.ts | 4 +- .../tests/FormApi/submission-handling.spec.ts | 9 +- .../tests/FormApi/validation.spec.ts | 12 +- .../tests/FormGroupApi/FormGroupApi.spec.ts | 4 +- .../form-core/tests/standardSchema.spec.ts | 178 +-------- .../tests/validation-output.test-d.ts | 14 +- packages/form-core/tests/validation.test-d.ts | 17 +- .../validation/createErrorMap.lib.spec.ts | 48 +-- .../src/PreactForm/Components.public.ts | 107 +++--- .../src/PreactForm/formApiTypes.public.ts | 18 +- .../src/AppForm/ReactAppFormApi.public.tsx | 2 +- .../src/AppForm/createFormHookTypes.public.ts | 1 - .../src/ReactForm/Components.public.ts | 1 - .../src/ReactForm/formApiTypes.public.ts | 7 +- .../src/ReactForm/formType.public.ts | 1 - .../src/ReactForm/useForm.public.ts | 1 - .../react-form/tests/FormGroup.test-d.tsx | 12 +- .../tests/createFormHook.test-d.tsx | 10 +- .../react-form/tests/submit-return.test-d.tsx | 355 ++++++++---------- 40 files changed, 498 insertions(+), 790 deletions(-) diff --git a/packages/form-core/src/FieldApi/FieldApi.public.ts b/packages/form-core/src/FieldApi/FieldApi.public.ts index 33a2ee1565..762c55fba1 100644 --- a/packages/form-core/src/FieldApi/FieldApi.public.ts +++ b/packages/form-core/src/FieldApi/FieldApi.public.ts @@ -8,10 +8,7 @@ import type { } from '../validation.public' import type { FieldListeners } from '../listeners.public' import type { ReadonlyAtom } from '@tanstack/store' -import type { - ScopedErrorTypes, - ToFormErrorTypes, -} from '../validation/state.public' +import type { ScopedErrorTypes } from '../validation/state.public' import type { ErrorVisibilityFn } from '../FormApi/errorVisibility.public' export interface BaseFieldMeta { diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 167ac831a2..1a1b32e633 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -80,7 +80,6 @@ import type { FormValidationFailure, FormValidationResult, ScopedErrorTypes, - ToScopedErrorTypes, ValidationIssue, } from '../validation/state.public' import type { FormOptions } from './formOptions.public' diff --git a/packages/form-core/src/FormApi/errorVisibility.public.ts b/packages/form-core/src/FormApi/errorVisibility.public.ts index 7d0eb2b4f1..e4573f7f5f 100644 --- a/packages/form-core/src/FormApi/errorVisibility.public.ts +++ b/packages/form-core/src/FormApi/errorVisibility.public.ts @@ -1,7 +1,4 @@ -import type { - ScopedErrorTypes, - ToFormErrorTypes, -} from '../validation/state.public' +import type { ScopedErrorTypes } from '../validation/state.public' import type { FormState } from './FormApi.public' export interface ErrorVisibilitySubfieldsMeta { diff --git a/packages/form-core/src/FormApi/formListeners.public.ts b/packages/form-core/src/FormApi/formListeners.public.ts index 1835dd6de1..bf0c632629 100644 --- a/packages/form-core/src/FormApi/formListeners.public.ts +++ b/packages/form-core/src/FormApi/formListeners.public.ts @@ -1,10 +1,7 @@ import type { AnyFieldApi } from '../FieldApi/FieldApi.public' import type { Listener } from '../listeners.public' import type { ClientSideEvents, PreprocessEvents } from '../events.public' -import type { - ScopedErrorTypes, - ToFormErrorTypes, -} from '../validation/state.public' +import type { ScopedErrorTypes } from '../validation/state.public' import type { FormApi } from './FormApi.public' export type FormListenerTriggers = ClientSideEvents | PreprocessEvents | 'reset' diff --git a/packages/form-core/src/FormApi/formOptions.public.ts b/packages/form-core/src/FormApi/formOptions.public.ts index c252371019..b09b84bb4a 100644 --- a/packages/form-core/src/FormApi/formOptions.public.ts +++ b/packages/form-core/src/FormApi/formOptions.public.ts @@ -163,7 +163,10 @@ export interface FormOptions< */ listeners?: FormListeners< NoInfer, - NoInfer> + // TSubmitReturn would make it depend on property order (putting `onSubmit` before or after) + // Overall, it's not really necessary for the listeners section, since we can guarantee that + // a submit error that might exist must have the `ValidationIssue` signature. + NoInfer> > /** * Used by server-side rendering (SSR) integrations to restore form state diff --git a/packages/form-core/src/FormApi/formSubmit.public.ts b/packages/form-core/src/FormApi/formSubmit.public.ts index faf742f090..3a92e5bd3f 100644 --- a/packages/form-core/src/FormApi/formSubmit.public.ts +++ b/packages/form-core/src/FormApi/formSubmit.public.ts @@ -1,12 +1,14 @@ import type { StandardSchemaV1 } from '../standardSchema.public' import type { PARSED_ISSUES } from '../validation/parseIssues.lib' import type { - CreateErrorMapFn, - CreatedErrorMap, + FormValidationFailure, + InferredErrorMapItem, ParseIssuesFn, ScopedErrorTypes, + ValidationErrorMap, ValidationFailure, ValidationFailureItem, + ValidationFieldMap, ValidationOutput, } from '../validation/state.public' import type { FormApi } from './FormApi.public' @@ -61,24 +63,37 @@ export type OnSubmitError = { readonly [submitErrorSymbol]: T } -export type CreateValidationErrorFn = ( +export type CreateValidationErrorFn = < + TError extends FormValidationFailure, +>( error: TError, ) => OnSubmitError -type SubmitErrorMap = CreatedErrorMap< - TFormData, - ValidationFailureItem -> type ParsedSubmitIssues = ReturnType> +export type CreatedSubmitErrorMap< + TFormData, + TFormError extends ValidationFailure, + TFieldError extends ValidationFailure, +> = Required> & + OnSubmitError< + Required> + > + /** * Builds a mutable error map to return from `onSubmit` and report validation errors. * * @typeParam TFormData - Library-managed. Do not specify explicitly. */ export type CreateSubmitErrorMapFn = ( - initial?: Parameters>[0], -) => SubmitErrorMap & OnSubmitError> + initial?: Partial< + ValidationErrorMap + >, +) => CreatedSubmitErrorMap< + TFormData, + Array, + ValidationFailure +> /** * Converts Standard Schema issues into a mutable error map to return from `onSubmit`. @@ -148,7 +163,7 @@ export interface FormSubmitContext< * from a submission handler. Creating the error alone does not update form or * field validation state. */ - createValidationError: CreateValidationErrorFn + createValidationError: CreateValidationErrorFn /** * Builds a mutable submission error map from optional initial errors. Return * the map to apply its form and field errors. An empty map indicates success. diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index 19092ef862..4b801ba02e 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -29,7 +29,7 @@ function isSubmitError( ) } -const createValidationError: CreateValidationErrorFn = < +const createValidationError: CreateValidationErrorFn = < TError extends FormValidationFailure, >( error: TError, @@ -155,7 +155,7 @@ export async function runSubmissionProcess( return errorResults.flatMap>((error) => { const { self, subfields } = parseValidationResult(error) if (isValidationErrorMap(error)) { - return [{ ...error, form: self ?? undefined, fields: subfields ?? {} }] + return [{ ...error, form: self ?? [], fields: subfields ?? {} }] } return self ?? [] }) @@ -175,7 +175,7 @@ export async function runSubmissionProcess( value, createValidationError, createErrorMap: withSubmitError(createErrorMap), - parseIssues: withSubmitError(parseIssuesFactory(value)), + parseIssues: withSubmitError(parseIssuesFactory(value, 'field')), } satisfies FormSubmitContext as any) if (hasResettedFormDuringSubmit()) { diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index bddc8249c4..1cf4ef4e13 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -53,8 +53,6 @@ import type { } from '../ValidatorInstance.lib' import type { ScopedErrorTypes, - ToFormErrorTypes, - ToScopedErrorTypes, ValidationIssue, } from '../validation/state.public' @@ -680,8 +678,7 @@ export class InternalFormGroupApi< triggerFieldApi: opts?.triggerFieldApi, }, hasFailedBefore: false, - scope: 'form', - getContext: (ctx) => ({ + getContext: (ctx, instance) => ({ event: signal, formApi: this.form, groupApi: this, @@ -689,7 +686,10 @@ export class InternalFormGroupApi< signal: ctx.signal, value: this.value, createErrorMap, - parseIssues: parseIssuesFactory(this.value), + parseIssues: parseIssuesFactory( + this.value, + instance.fieldErrorTarget, + ), createOutput, }), onResult: (result) => { diff --git a/packages/form-core/src/ValidatorInstance.lib.ts b/packages/form-core/src/ValidatorInstance.lib.ts index a7f6ff9bbc..807bba0733 100644 --- a/packages/form-core/src/ValidatorInstance.lib.ts +++ b/packages/form-core/src/ValidatorInstance.lib.ts @@ -1,14 +1,12 @@ import { LiteDebouncer } from '@tanstack/pacer-lite' import { InternalValidationSourceInstance } from './ValidationSourceInstance.lib' -import type { StandardSchemaV1 } from './standardSchema.public' import type { - BaseValidator, FieldValidator, FormGroupValidator, - ValidatorFn, ValidatorScope, } from './validation.public' import type { FormValidator } from './validation/formValidators.public' +import type { FieldErrorTarget } from './validation/state.public' type GetDefinition = { form: FormValidator @@ -29,7 +27,7 @@ export interface InternalValidatorInstanceOptions< > { owner: TOwner scope: TScope - definition: any + definition: GetDefinition index?: number } @@ -132,6 +130,7 @@ export class InternalValidatorInstance< * Assigning the same definition again still advances the revision. */ revision = 0 + fieldErrorTarget: FieldErrorTarget /** Creates the runtime state for one installed validator occurrence. */ constructor({ definition, @@ -141,6 +140,10 @@ export class InternalValidatorInstance< }: InternalValidatorInstanceOptions) { super({ owner, scope, index }) this.definition = definition + this.fieldErrorTarget = 'field' + if ('fieldErrorTarget' in definition && definition.fieldErrorTarget) { + this.fieldErrorTarget = definition.fieldErrorTarget + } } /** @@ -153,6 +156,10 @@ export class InternalValidatorInstance< if (this.disposed) return this.definition = definition + this.fieldErrorTarget = 'field' + if ('fieldErrorTarget' in definition && definition.fieldErrorTarget) { + this.fieldErrorTarget = definition.fieldErrorTarget + } this.revision++ } diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index 6b8d6ce5bc..a7a44828df 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -154,16 +154,15 @@ export async function validateServerValues< formApi: undefined, }, hasFailedBefore: false, - getContext: (ctx) => ({ - event: 'server', + getContext: (ctx, instance) => ({ + event: 'server' as const, signal: ctx.signal, formApi: undefined, value: values, createErrorMap, - parseIssues: parseIssuesFactory(values), + parseIssues: parseIssuesFactory(values, instance.fieldErrorTarget), createOutput, }), - scope: 'form', }) if (pipelineResult.thrownError !== null) { diff --git a/packages/form-core/src/standardSchema.lib.ts b/packages/form-core/src/standardSchema.lib.ts index 576cd8a54f..6e304c6d26 100644 --- a/packages/form-core/src/standardSchema.lib.ts +++ b/packages/form-core/src/standardSchema.lib.ts @@ -1,6 +1,7 @@ import { isNil } from './utils.lib' import { createOutput } from './validationOutput.lib' -import { PARSED_ISSUES } from './validation/parseIssues.lib' +import { PARSED_ISSUES, parseIssuesFactory } from './validation/parseIssues.lib' +import { validationSourceScopes } from './ValidationSourceInstance.lib' import type { StandardSchemaV1, StandardSchemaV1Issue, @@ -12,23 +13,34 @@ import type { import type { FormValidatorFn } from './validation/formValidators.public' import type { ParsedStandardSchemaIssues, + ValidationFieldMap, ValidationOutput, } from './validation/state.public' +import type { AnyInternalValidatorInstance } from './ValidatorInstance.lib' -export function prefixSchemaToErrors( +export function parseIssuesToErrorMap( issues: ReadonlyArray, formValue: unknown, ) { const schema = new Map>() + const formLevel = [] as Array for (const issue of issues) { const path = getPathFromIssue(issue, formValue) - if (path === '') continue - - schema.set(path, (schema.get(path) ?? []).concat(issue)) + if (path === '') { + formLevel.push(issue) + } else { + schema.set(path, (schema.get(path) ?? []).concat(issue)) + } } - return Object.fromEntries(schema) + return { + form: formLevel, + fields: Object.fromEntries(schema) as ValidationFieldMap< + TFormData, + Array + >, + } } function getPathFromIssue( @@ -80,51 +92,22 @@ export function isStandardSchema( export function parseStandardSchema( schema: StandardSchemaV1, value: any, - scope: 'form' | 'field', + instance: AnyInternalValidatorInstance, ): Promise< | ValidationOutput | ParsedStandardSchemaIssues - | Array + | ReadonlyArray > { + const parseIssues = parseIssuesFactory(value, instance.fieldErrorTarget) + return Promise.resolve(schema['~standard'].validate(value)).then((result) => { if (!result.issues) { return createOutput(result.value) } - return parseStandardSchemaIssues(result.issues, value, scope) + if (instance.scope === validationSourceScopes.field) { + return result.issues + } + return parseIssues(result.issues) }) } - -export function parseStandardSchemaIssues( - issues: ReadonlyArray, - value: TValue, - scope: 'form', -): ParsedStandardSchemaIssues -export function parseStandardSchemaIssues( - issues: ReadonlyArray, - value: unknown, - scope: 'field', -): Array -export function parseStandardSchemaIssues( - issues: ReadonlyArray, - value: TValue, - scope: 'form' | 'field', -): ParsedStandardSchemaIssues | Array -export function parseStandardSchemaIssues( - issues: ReadonlyArray, - value: TValue, - scope: 'form' | 'field', -): ParsedStandardSchemaIssues | Array { - if (scope === 'field') { - return issues.slice() - } - - return { - [PARSED_ISSUES]: true, - form: issues.slice(), - fields: prefixSchemaToErrors( - issues, - value, - ) as ParsedStandardSchemaIssues['fields'], - } -} diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/validation.public.ts index 64fec2ca40..91c4402f38 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/validation.public.ts @@ -608,7 +608,8 @@ export interface FormGroupValidatorContext { * @typeParam TGroupValue - Library-managed. Do not specify explicitly. */ export type FormGroupValidationError = - ValidationErrorInput | ValidationErrorMap + | ValidationErrorInput + | ValidationErrorMap /** * A form group validator's success result, output data, or validation errors. * @@ -750,7 +751,7 @@ type ExtractErrorMap< TTarget extends ValidationErrorTarget, > = TResult extends ValidationIssue ? NormalizeValidationResult - : TResult extends ValidationErrorMap + : TResult extends ValidationErrorMap ? TTarget extends 'form' ? TResult extends { form?: infer TError } ? NormalizeValidationResult @@ -760,22 +761,6 @@ type ExtractErrorMap< : never : NormalizeValidationResult -/** - * Inferred issue types for errors owned by a form or group and errors routed to its fields. - * - * @typeParam TFormError - Library-managed. Do not specify explicitly. - * @typeParam TFieldError - Library-managed. Do not specify explicitly. - */ -export interface FormErrorTypes< - out TFormError = ValidationIssue, - out TFieldError = ValidationIssue, -> { - /** The issue type stored in the form or group's `state.errors`. */ - readonly formError: TFormError - /** The issue type routed from form or group validation to individual fields. */ - readonly fieldError: TFieldError -} - type ExtractSubmitValidationError = Awaited extends infer TResolved ? TResolved extends OnSubmitError @@ -801,25 +786,11 @@ type ExtractSubmitFieldError = unknown extends TSubmitReturn ? ValidationIssue : ParseSubmitFieldError -type ExtractFormError = - unknown extends TFormErrorTypes['formError'] - ? ValidationIssue - : TFormErrorTypes['formError'] - type ExtractFormFieldError = unknown extends TFormError['subfieldError'] ? never : TFormError['subfieldError'] -/** - * The form or group's error array, with string messages normalized to issue objects. - * - * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. - */ -export type FormErrors = Array< - ExtractFormError -> - /** * A field's error array, with string messages normalized to issue objects. * @@ -842,7 +813,7 @@ type SuccessWithoutOutput = : never : TResult extends ValidationIssue ? never - : TResult extends ValidationErrorMap + : TResult extends ValidationErrorMap ? TResult extends { form: infer TForm } ? [SuccessWithoutOutput] extends [never] ? never diff --git a/packages/form-core/src/validation/createErrorMap.lib.ts b/packages/form-core/src/validation/createErrorMap.lib.ts index 547db8b8ad..219250c16d 100644 --- a/packages/form-core/src/validation/createErrorMap.lib.ts +++ b/packages/form-core/src/validation/createErrorMap.lib.ts @@ -6,25 +6,46 @@ import type { ValidationFailureItem, } from './state.public' -// TODO migrate to a proper file sometime. +function toArray(item: ValidationFailure | undefined): ValidationFailureItem[] { + if (isReadonlyArray(item)) return item.slice() + if (!item) return [] + return [item] +} -export const createErrorMap = (( - initial: Partial> = {}, -) => { - let form: ValidationFailureItem[] = [] - if (isReadonlyArray(initial.form)) { - form = initial.form.slice() - } else if (initial.form) { - form = [initial.form] +function mergeError( + before: ValidationFailureItem[] | undefined, + newError: ValidationFailure, +): ValidationFailureItem[] { + if (!before) { + return toArray(newError) + } else if (!newError) { + return before + } else { + before.push(...toArray(newError)) + return before } +} + +export const createErrorMap = (( + initial: Partial< + ValidationErrorMap + > = {}, +) => { + const form = toArray(initial.form) + const fields = Object.fromEntries( + Object.entries((initial.fields ?? {}) as never).map( + ([name, error]) => [name, toArray(error)], + ), + ) return { form, - fields: Object.fromEntries( - Object.entries(initial.fields ?? {}).map(([name, error]) => [ - name, - isReadonlyArray(error) ? error.slice() : error, - ]), - ), + fields, + addFormError: (error) => { + mergeError(form, error) + }, + addFieldError: (fieldName, error) => { + fields[fieldName] = mergeError(fields[fieldName], error) + }, } -}) as CreateErrorMapFn +}) satisfies CreateErrorMapFn as CreateErrorMapFn diff --git a/packages/form-core/src/validation/errors.lib.ts b/packages/form-core/src/validation/errors.lib.ts index ffebf8c2a0..a3e535f6ef 100644 --- a/packages/form-core/src/validation/errors.lib.ts +++ b/packages/form-core/src/validation/errors.lib.ts @@ -40,7 +40,7 @@ export interface ParsedValidationResult { */ export function isValidationErrorMap( value: unknown, -): value is ValidationErrorMap { +): value is ValidationErrorMap { if (typeof value !== 'object') return false if (value === null) return false if (Array.isArray(value)) return false diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index c1f26f1a96..5b59c1ad87 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -233,12 +233,12 @@ export function shouldRunValidator( * Schema and branded function outputs retain their payload and presence marker. */ export async function executeValidator( - validator: AnyPipelineValidator, + validatorInstance: AnyInternalValidatorInstance, context: AnyValidatorContext, - scope: 'field' | 'form', ): Promise> { + const validator = validatorInstance.definition const result = isStandardSchema(validator.run) - ? await parseStandardSchema(validator.run, context.value, scope) + ? await parseStandardSchema(validator.run, context.value, validatorInstance) : await validator.run(context) return normalizeValidatorResult(result as TResult) diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index da03b0ce60..1662a45b43 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -43,8 +43,10 @@ export interface FormMountValidatorPipelineResult { interface MountValidatorPipelineArgs { pipeline: ReadonlyArray context: InputContext - getContext: (signal: AbortSignal) => AnyValidatorContext - scope: 'field' | 'form' + getContext: ( + signal: AbortSignal, + instance: AnyInternalValidatorInstance, + ) => AnyValidatorContext onResult?: (result: PipelineResult) => void } @@ -88,7 +90,6 @@ function processMountValidationExecutionResult( */ function executeMountValidator( getContext: MountValidatorPipelineArgs['getContext'], - scope: 'field' | 'form', validatorInstance: AnyInternalValidatorInstance, ): | ValidatorExecutionResult @@ -98,11 +99,15 @@ function executeMountValidator( cancelDebouncer: true, }) - const context = getContext(signal) + const context = getContext(signal, validatorInstance) try { if (isStandardSchema(validator.run)) { - return parseStandardSchema(validator.run, context.value, scope) + return parseStandardSchema( + validator.run, + context.value, + validatorInstance, + ) .then((result) => { if (signal.aborted) { return createEmptyMountValidationResult() @@ -154,7 +159,6 @@ async function continueMountValidationFromAsyncResult< >( pipeline: ReadonlyArray, getContext: MountValidatorPipelineArgs['getContext'], - scope: 'field' | 'form', startIndex: number, firstResult: PromiseLike>, hasFailedBefore: boolean, @@ -179,11 +183,7 @@ async function continueMountValidationFromAsyncResult< if (validator.bailIfInvalid && hasFailed) break - const result = executeMountValidator( - getContext, - scope, - validatorInstance, - ) + const result = executeMountValidator(getContext, validatorInstance) const executionResult = isPromiseLike(result) ? await result : result if ( @@ -208,7 +208,6 @@ function runMountValidatorPipeline({ pipeline, context, getContext, - scope, onResult, }: MountValidatorPipelineArgs): FormMountValidatorPipelineResult { const mountPipeline = pipeline.filter((validatorInstance) => @@ -234,11 +233,7 @@ function runMountValidatorPipeline({ } } - const result = executeMountValidator( - getContext, - scope, - validatorInstance, - ) + const result = executeMountValidator(getContext, validatorInstance) if (isPromiseLike(result)) { return { @@ -246,7 +241,6 @@ function runMountValidatorPipeline({ asyncPromise: continueMountValidationFromAsyncResult( mountPipeline, getContext, - scope, i, result, hasFailed, @@ -277,16 +271,18 @@ export function runFormMountValidatorPipeline({ return runMountValidatorPipeline>({ pipeline, context: { scope: 'form', event: 'mount', formApi }, - getContext: (signal) => ({ + getContext: (signal, instance) => ({ event: 'mount', signal, formApi, value: formApi.state.values, createErrorMap, - parseIssues: parseIssuesFactory(formApi.state.values), + parseIssues: parseIssuesFactory( + formApi.state.values, + instance.fieldErrorTarget, + ), createOutput, }), - scope: 'form', onResult, }) } @@ -318,7 +314,6 @@ export function runFieldMountValidatorPipeline({ fieldApi: fieldApi as never, value: fieldApi.value, }), - scope: 'field', onResult, }) } @@ -345,18 +340,20 @@ export function runGroupMountValidatorPipeline({ formApi: groupApi.form, groupApi, }, - getContext: (signal) => ({ - event: 'mount', + getContext: (signal, instance) => ({ + event: 'mount' as const, signal, formApi: groupApi.form as never, groupApi: groupApi as never, triggerFieldApi: undefined, value: groupApi.value, createErrorMap, - parseIssues: parseIssuesFactory(groupApi.value), + parseIssues: parseIssuesFactory( + groupApi.value, + instance.fieldErrorTarget, + ), createOutput, }), - scope: 'form', onResult, }) } diff --git a/packages/form-core/src/validation/parseIssues.lib.ts b/packages/form-core/src/validation/parseIssues.lib.ts index 567479fc3f..22e5fa452d 100644 --- a/packages/form-core/src/validation/parseIssues.lib.ts +++ b/packages/form-core/src/validation/parseIssues.lib.ts @@ -1,19 +1,24 @@ -import { prefixSchemaToErrors } from '../standardSchema.lib' -import type { ParseIssuesFn, ParsedStandardSchemaIssues } from './state.public' +import { parseIssuesToErrorMap } from '../standardSchema.lib' +import type { FieldErrorTarget, ParseIssuesFn } from './state.public' export const PARSED_ISSUES = Symbol('PARSED_ISSUES') -// TODO: Add per-call fieldErrorTarget configuration when wiring up validation, -// defaulting path-based issues to fields and always keeping root issues in form. export function parseIssuesFactory( value: TFormData, + routeFieldErrorsTo: FieldErrorTarget, ): ParseIssuesFn { - return (issues) => ({ - [PARSED_ISSUES]: true, - form: issues.slice(), - fields: prefixSchemaToErrors( - issues, - value, - ) as ParsedStandardSchemaIssues['fields'], - }) + return (issues) => { + const map = parseIssuesToErrorMap(issues, value) + + // `map.form` is only issues that have no destination. + const form = routeFieldErrorsTo === 'field' ? map.form : issues.slice() + + const fields = routeFieldErrorsTo === 'self' ? {} : map.fields + + return { + [PARSED_ISSUES]: true, + form, + fields, + } + } } diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts index a7c1d4d21b..4400e4af88 100644 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -44,8 +44,10 @@ interface ValidatorPipelineArgs { context: InputContext pipeline: ReadonlyArray hasFailedBefore: boolean - getContext: (inputContext: ValidateContext) => AnyValidatorContext - scope: 'field' | 'form' + getContext: ( + inputContext: ValidateContext, + instance: AnyInternalValidatorInstance, + ) => AnyValidatorContext validatorInstancesToRun?: ReadonlySet | null onResult?: (result: PipelineResult) => void } @@ -128,7 +130,6 @@ export async function runValidatorPipeline({ hasFailedBefore = false, getContext, onResult, - scope, validatorInstancesToRun = null, }: ValidatorPipelineArgs): Promise<{ results: Array> @@ -190,7 +191,10 @@ export async function runValidatorPipeline({ validatorInstance, context, onExecute: (ctx) => { - return executeValidator(validator, getContext(ctx), scope) + return executeValidator( + validatorInstance, + getContext(ctx, validatorInstance), + ) }, }).then< PendingPipelineResult< @@ -245,7 +249,7 @@ export function runFormValidatorPipeline({ context, onResult, hasFailedBefore, - getContext: (ctx) => { + getContext: (ctx, instance) => { if (isServerValidateContext(ctx)) { throw new Error('Server validation cannot run through client pipeline') } @@ -256,7 +260,10 @@ export function runFormValidatorPipeline({ signal: ctx.signal, value: ctx.formApi.state.values, createErrorMap, - parseIssues: parseIssuesFactory(ctx.formApi.state.values), + parseIssues: parseIssuesFactory( + ctx.formApi.state.values, + instance.fieldErrorTarget, + ), createOutput, } @@ -272,7 +279,6 @@ export function runFormValidatorPipeline({ fieldApi: ctx.fieldApi, } }, - scope: 'form', }) } @@ -333,7 +339,6 @@ export function runFieldValidatorPipeline({ value: context.fieldApi.value, } }, - scope: 'field', validatorInstancesToRun, }) } diff --git a/packages/form-core/src/validation/state.public.ts b/packages/form-core/src/validation/state.public.ts index 9cff4dd672..fae2b7cd17 100644 --- a/packages/form-core/src/validation/state.public.ts +++ b/packages/form-core/src/validation/state.public.ts @@ -5,8 +5,6 @@ import type { StandardSchemaV1Issue, } from '../standardSchema.public' import type { OneOrMany, ReadonlyOneOrMany } from '../types.public' -// TODO finalize migration -import type { FormErrorTypes } from '../validation.public' import type { VALIDATION_OUTPUT } from '../validationOutput.lib' import type { PARSED_ISSUES } from './parseIssues.lib' @@ -14,6 +12,8 @@ export interface ValidationIssue { message: string } +export type FieldErrorTarget = 'field' | 'field-and-self' | 'self' + /** * The result of `createOutput`. Return it from a validator to store its data * during submission. @@ -60,27 +60,37 @@ export type ValidationFieldMap = Partial< export interface ValidationErrorMap< TFormData, - TFormError, - TFieldError = TFormError, + TFormError extends ValidationFailure, + TFieldError extends ValidationFailure, > { form?: TFormError fields: ValidationFieldMap } +export interface ValidationErrorMapHelper extends Required< + ValidationErrorMap< + TFormData, + ValidationFailureItem[], + ValidationFailureItem[] + > +> { + addFormError: (error: ValidationFailure) => void + addFieldError: (name: DeepKeys, error: ValidationFailure) => void +} + export type FormValidationFailure = - ValidationFailure | ValidationErrorMap + | ValidationFailure + | ValidationErrorMap export type FormValidationResult = FormValidationFailure | ValidationSuccess -export interface CreatedErrorMap { - form: Array - fields: ValidationFieldMap> -} - -export interface ParsedStandardSchemaIssues extends CreatedErrorMap< - TFormData, - StandardSchemaV1Issue +export interface ParsedStandardSchemaIssues extends Required< + ValidationErrorMap< + TFormData, + Array, + Array + > > { readonly [PARSED_ISSUES]: true } @@ -93,30 +103,17 @@ type ErrorMapItem = unknown extends TError ? string : TError -type InferredErrorMapItem = [ErrorMapItem] extends [never] +export type InferredErrorMapItem = [ErrorMapItem] extends [ + never, +] ? ValidationFailureItem : ErrorMapItem -type ValidateFieldsMap< - TFormData, - TFieldErrors extends ValidationFieldMap, -> = { - // TODO why do we need this double layering? - [TName in keyof TFieldErrors]: TName extends DeepKeys - ? TFieldErrors[TName] - : never -} - -export type CreateErrorMapFn = < - TFormError extends ValidationFailure = never, - TFieldErrors extends ValidationFieldMap = {}, ->(initial?: { - form?: TFormError - fields?: ValidateFieldsMap -}) => CreatedErrorMap< - TFormData, - InferredErrorMapItem -> +export type CreateErrorMapFn = ( + initial?: Partial< + ValidationErrorMap + >, +) => ValidationErrorMapHelper export type ParseIssuesFn = ( issues: ReadonlyArray, @@ -208,12 +205,16 @@ type GetValidatorsError = ? GetValidatorError : never -type GetSubmitError = - Awaited extends infer TResult - ? TResult extends OnSubmitError - ? GetResultError +type GetSubmitError = 0 extends 1 & + TSubmitReturn + ? any + : unknown extends TSubmitReturn + ? ValidationIssue + : Awaited extends infer TResult + ? TResult extends OnSubmitError + ? GetResultError + : never : never - : never type GetSelfErrors = | GetValidatorsError @@ -227,15 +228,3 @@ export type GetFormErrorTypes = ScopedErrorTypes< GetSelfErrors, GetSubfieldErrors > - -// TODO get rid of this to complete migration -export type ToFormErrorTypes = - TScope extends ScopedErrorTypes - ? FormErrorTypes - : never - -// TODO get rid of this to complete migration -export type ToScopedErrorTypes = - TError extends FormErrorTypes - ? ScopedErrorTypes - : never diff --git a/packages/form-core/tests/FormApi/FormApi.public.test-d.ts b/packages/form-core/tests/FormApi/FormApi.public.test-d.ts index 7b9c0c0e0d..4cbf37547d 100644 --- a/packages/form-core/tests/FormApi/FormApi.public.test-d.ts +++ b/packages/form-core/tests/FormApi/FormApi.public.test-d.ts @@ -2,12 +2,9 @@ import { describe, expectTypeOf, it } from 'vitest' import { formOptions } from '../../src' import type { CreateSubmitErrorMapFn, - CreatedErrorMap, GetFormErrorTypes, - OnSubmitError, ParseSubmitIssuesFn, StandardSchemaV1Issue, - ValidationFailureItem, ValidationIssue, } from '../../src' @@ -24,10 +21,6 @@ describe('FormSubmitContext', () => { expectTypeOf(parseIssues).toEqualTypeOf>() const errors = createErrorMap({ form: 'Submission failed' }) - expectTypeOf(errors).toEqualTypeOf< - CreatedErrorMap & - OnSubmitError> - >() errors.fields.name = 'Name is required' errors.fields['user.age'] = { message: 'Age is required' } diff --git a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts index 658d9263ca..83ec860323 100644 --- a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -10,7 +10,7 @@ import type { CreateErrorMapFn, ParseIssuesFn, ParsedStandardSchemaIssues, - ValidationFailureItem, + ValidationIssue, } from '../../src/validation/state.public' describe('formOptions reuse', () => { @@ -1045,21 +1045,26 @@ describe('formOptions listener inference', () => { run: ({ value, createOutput }) => createOutput(value.name.length), }, ], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf<[undefined, number]>() + }, listeners: [ { triggers: ['change'], run: ({ value, formApi }) => { expectTypeOf(value).toEqualTypeOf<{ name: string }>() + // ValidationIssue here comes from listeners ignoring `onSubmit`. + // This needs to be changed if we adjust this in the future. + expectTypeOf(formApi.state.errors).toEqualTypeOf< + Array<{ message: string; code: 'form' } | ValidationIssue> + >() expectTypeOf(formApi.state.errors).toEqualTypeOf< - Array<{ message: string; code: 'form' }> + Array<{ message: string; code: 'form' } | ValidationIssue> >() expectTypeOf(formApi.state.values).toEqualTypeOf<{ name: string }>() }, }, ], - onSubmit: ({ validatorOutputs }) => { - expectTypeOf(validatorOutputs).toEqualTypeOf<[undefined, number]>() - }, }) }) }) @@ -1078,8 +1083,6 @@ describe('formOptions validator inference', () => { const errors = createErrorMap({ form: ['Please check the form'] }) errors.fields['users[0].name'] = 'Required' - expectTypeOf(errors.form).toEqualTypeOf>() - // @ts-expect-error Error maps retain the inferred error item type. errors.form.push({ message: 'Invalid' }) // @ts-expect-error Error map fields must belong to the form values. errors.fields.missing = 'Invalid' @@ -1087,17 +1090,8 @@ describe('formOptions validator inference', () => { const issues: ReadonlyArray = [ { message: 'Required', path: ['users', 0, 'name'] }, ] - const parsed = parseIssues(issues) - expectTypeOf(parsed).toEqualTypeOf< - ParsedStandardSchemaIssues - >() - parsed.form.push({ message: 'Invalid form' }) - // @ts-expect-error Parsed error maps retain the form's field paths. - parsed.fields.missing = { message: 'Invalid' } - // @ts-expect-error Standard Schema issues belong to parseIssues. - createErrorMap(issues) - return parsed + return parseIssues(issues) }, }, ], @@ -1609,7 +1603,6 @@ describe('formOptions onSubmit inference', () => { defaultValues: { name: '' }, onSubmit: ({ createErrorMap, parseIssues }) => { const errors = createErrorMap({ fields: { name: 'Required' } }) - expectTypeOf(errors.form).toEqualTypeOf>() errors.form.push('Please check the form') // @ts-expect-error Submission errors retain the form's field paths. errors.fields.missing = 'Invalid' diff --git a/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts index 7175a12caf..b9f376f1cf 100644 --- a/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts +++ b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts @@ -20,10 +20,10 @@ describe('runSubmissionProcess', () => { const result = await form.handleSubmit() expect(result).toEqual([ - { + expect.objectContaining({ form: [{ message: 'Submission failed' }], fields: { name: [{ message: 'Name is required' }] }, - }, + }), ]) expect(form.state.errors).toEqual([{ message: 'Submission failed' }]) expect(field.errors).toEqual([{ message: 'Name is required' }]) diff --git a/packages/form-core/tests/FormApi/submission-handling.spec.ts b/packages/form-core/tests/FormApi/submission-handling.spec.ts index 25b34be8ec..c26a65b482 100644 --- a/packages/form-core/tests/FormApi/submission-handling.spec.ts +++ b/packages/form-core/tests/FormApi/submission-handling.spec.ts @@ -214,12 +214,13 @@ describe('form - submission handling', () => { expect(result).toEqual([ expect.objectContaining({ - form: [expect.objectContaining({ message: 'Name is required' })], + form: [], + fields: { + name: [expect.objectContaining({ message: 'Name is required' })], + }, }), ]) - expect(form.state.errors).toEqual([ - expect.objectContaining({ message: 'Name is required' }), - ]) + expect(form.state.errors).toEqual([]) expect(field.errors).toEqual([ expect.objectContaining({ message: 'Name is required' }), ]) diff --git a/packages/form-core/tests/FormApi/validation.spec.ts b/packages/form-core/tests/FormApi/validation.spec.ts index c84c8d6b52..5a2850d207 100644 --- a/packages/form-core/tests/FormApi/validation.spec.ts +++ b/packages/form-core/tests/FormApi/validation.spec.ts @@ -139,9 +139,7 @@ describe('form - validation', () => { await form.validate('submit') - expect(form.state.errors).toEqual([ - expect.objectContaining({ message: 'Name is required' }), - ]) + expect(form.state.errors).toEqual([]) expect(field.errors).toEqual([ expect.objectContaining({ message: 'Name is required' }), ]) @@ -667,8 +665,12 @@ describe('form - validation', () => { ], }) + const field = form._getOrCreateFieldApi({ + name: 'name', + }) + await vi.waitFor(() => { - expect(form.state.errors).toEqual([ + expect(field.errors).toEqual([ expect.objectContaining({ message: 'Name is required' }), ]) }) @@ -1144,7 +1146,7 @@ describe('form - validation', () => { expect.objectContaining({ message: 'Start date is required' }), expect.objectContaining({ message: 'End date is required' }), ]) - expect(form.state.errors).toHaveLength(2) + expect(form.state.errors).toHaveLength(0) expect(form._tryGetFieldApi('range.from')).toBeNull() expect(form._tryGetFieldApi('range.to')).toBeNull() }) diff --git a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index fafa10353b..68cf3823c7 100644 --- a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts @@ -2043,9 +2043,7 @@ describe('FormGroupApi', () => { await group.validate('submit') - expect(group.state.errors).toEqual([ - expect.objectContaining({ message: 'Name is required' }), - ]) + expect(group.state.errors).toEqual([]) expect(nameField.errors).toEqual([ expect.objectContaining({ message: 'Name is required' }), ]) diff --git a/packages/form-core/tests/standardSchema.spec.ts b/packages/form-core/tests/standardSchema.spec.ts index 661c7a0213..e7ddb793f6 100644 --- a/packages/form-core/tests/standardSchema.spec.ts +++ b/packages/form-core/tests/standardSchema.spec.ts @@ -1,14 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' -import { - isStandardSchema, - parseStandardSchema, - prefixSchemaToErrors, -} from '../src/standardSchema.lib' -import { VALIDATION_OUTPUT } from '../src/validationOutput.lib' -import type { - StandardSchemaV1, - StandardSchemaV1Issue, -} from '../src/standardSchema.public' +import { describe, expect, it } from 'vitest' +import { isStandardSchema } from '../src/standardSchema.lib' +import type { StandardSchemaV1 } from '../src/standardSchema.public' function getSchema( validate: StandardSchemaV1['~standard']['validate'], @@ -23,97 +15,6 @@ function getSchema( } describe('standard schema validation', () => { - describe('prefixSchemaToErrors', () => { - it('does not use an empty string as a field path for root issues', () => { - const issues = [ - { message: 'Invalid form' }, - { message: 'Still invalid', path: [] }, - { message: 'Empty string paths are root errors', path: [''] }, - ] satisfies Array - - expect(prefixSchemaToErrors(issues, {})).toEqual({}) - }) - - it('prefixes nested object paths and preserves multiple issues for the same field', () => { - const issues = [ - { message: 'Name is required', path: ['user', 'name'] }, - { message: 'Name is too short', path: ['user', { key: 'name' }] }, - ] satisfies Array - - expect(prefixSchemaToErrors(issues, { user: { name: '' } })).toEqual({ - 'user.name': issues, - }) - }) - - it('uses bracket notation for array indexes from number or string path segments', () => { - const issues = [ - { message: 'First name is required', path: ['users', 0, 'name'] }, - { message: 'Second name is required', path: ['users', '1', 'name'] }, - ] satisfies Array - - expect( - prefixSchemaToErrors(issues, { - users: [{ name: '' }, { name: '' }], - }), - ).toEqual({ - 'users[0].name': [issues[0]], - 'users[1].name': [issues[1]], - }) - }) - - it('keeps building the path after traversal reaches a primitive value', () => { - const issue = { - message: 'Length is too short', - path: ['name', 'length'], - } satisfies StandardSchemaV1Issue - - expect(prefixSchemaToErrors([issue], { name: '' })).toEqual({ - 'name.length': [issue], - }) - }) - - it('skips undefined path segments', () => { - const issue = { - message: 'Value is required', - path: ['user', undefined, 'name'], - } as StandardSchemaV1Issue - - expect(prefixSchemaToErrors([issue], { user: { name: '' } })).toEqual({ - 'user.name': [issue], - }) - }) - - it('skips empty string path segments as no accessor', () => { - const issues = [ - { message: 'Middle segment', path: ['foo', '', 'bar'] }, - { message: 'Leading segment', path: ['', 'bar'] }, - { message: 'Object segment', path: ['foo', { key: '' }, 'baz'] }, - ] satisfies Array - - expect( - prefixSchemaToErrors(issues, { - foo: { bar: '', baz: '' }, - bar: '', - }), - ).toEqual({ - 'foo.bar': [issues[0]], - bar: [issues[1]], - 'foo.baz': [issues[2]], - }) - }) - - it('does not treat empty string path segments as array indexes', () => { - const issue = { - message: 'Name is required', - path: ['users', '', 'name'], - } satisfies StandardSchemaV1Issue - - expect(prefixSchemaToErrors([issue], { users: [{ name: '' }] })).toEqual({ - 'users.name': [issue], - }) - }) - }) - describe('isStandardSchema', () => { it('identifies standard schema objects', () => { expect(isStandardSchema(getSchema((value) => ({ value })))).toBe(true) @@ -126,77 +27,4 @@ describe('standard schema validation', () => { ).toBe(false) }) }) - - describe('parseStandardSchema', () => { - it('returns branded schema output for successful validation', async () => { - const schema = getSchema((value) => ({ - value: { parsed: String(value).toUpperCase() }, - })) - - const result = await parseStandardSchema(schema, 'value', 'form') - - expect(result).toEqual({ - [VALIDATION_OUTPUT]: { parsed: 'VALUE' }, - }) - }) - - it('returns field issues directly for field scope', async () => { - const issues = [ - { message: 'Field is required', path: ['name'] }, - ] satisfies Array - const schema = getSchema(() => ({ issues })) - - const result = await parseStandardSchema(schema, '', 'field') - - expect(result).toEqual(issues) - }) - - it('returns prefixed field errors for form scope', async () => { - const issues = [ - { message: 'Name is required', path: ['users', 0, 'name'] }, - ] satisfies Array - const schema = getSchema(() => Promise.resolve({ issues })) - - const result = await parseStandardSchema( - schema, - { users: [{ name: '' }] }, - 'form', - ) - - expect(result).toMatchObject({ - form: issues, - fields: { - 'users[0].name': issues, - }, - }) - }) - - it('returns all schema issues as form-level errors and filters root issues from fields', async () => { - const issues = [ - { message: 'Invalid form' }, - { message: 'Empty string paths are root errors', path: [''] }, - { message: 'Name is required', path: ['name'] }, - ] satisfies Array - const schema = getSchema(() => ({ issues })) - - const result = await parseStandardSchema(schema, { name: '' }, 'form') - - expect(result).toMatchObject({ - form: issues, - fields: { - name: [issues[2]], - }, - }) - }) - - it('calls validate with the provided value', async () => { - const validate = vi.fn((value: unknown) => ({ value })) - const schema = getSchema(validate) - const value = { name: 'Test' } - - await parseStandardSchema(schema, value, 'form') - - expect(validate).toHaveBeenCalledWith(value) - }) - }) }) diff --git a/packages/form-core/tests/validation-output.test-d.ts b/packages/form-core/tests/validation-output.test-d.ts index 3e6d7b3d8d..20f63456c4 100644 --- a/packages/form-core/tests/validation-output.test-d.ts +++ b/packages/form-core/tests/validation-output.test-d.ts @@ -1,17 +1,18 @@ import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { formOptions } from '../src' +import { InternalValidatorInstance } from '../src/ValidatorInstance.lib' import { parseStandardSchema, validateServerValues } from '../src/internals' import type { FieldValidatorContext, FieldValidators, FormGroupValidators, + FormOptionsValidators, FormValidator, GetFormErrorTypes, - FormOptionsValidators, + GetValidatorOutputs, ToFormGroupErrorTypes, ToFormGroupOutputs, - GetValidatorOutputs, ValidationErrorMap, ValidationIssue, ValidationOutput, @@ -37,7 +38,11 @@ describe('validator output types', () => { const result = parseStandardSchema( z.object({ name: z.string() }), { name: 'Tony' }, - 'form', + new InternalValidatorInstance({ + definition: { run: z.object({ name: z.string() }) }, + owner: undefined, + scope: 'form', + }), ) expectTypeOf< @@ -145,7 +150,8 @@ describe('validator output types', () => { createOutput, }): | ValidationOutput - | ValidationErrorMap => createOutput(1), + | ValidationErrorMap => + createOutput(1), triggers: [], }, { diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index f074fb0d86..59422bcef3 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -14,8 +14,6 @@ import type { FieldErrors, FieldValidators, FormApi, - FormErrorTypes, - FormErrors, FormGroupValidators, FormOptions, FormOptionsValidators, @@ -29,7 +27,6 @@ import type { ScopedErrorTypes, StandardSchemaV1Issue, ToFieldError, - ToFormErrorTypes, ToFormGroupErrorTypes, ToFormGroupOutputs, ValidationIssue, @@ -69,9 +66,7 @@ const emptyFieldValidators = defineFieldValidators([]) type TestFormErrors< TFormValidators extends ReadonlyArray, TSubmitReturn, -> = FormErrors< - ToFormErrorTypes> -> +> = Array['selfError']> type TestFieldErrors< TFieldValidators extends FieldValidators, @@ -758,7 +753,9 @@ describe('FormErrors', () => { it('does not infer submit errors from an unknown return', () => { expectTypeOf>().not.toBeAny() - expectTypeOf>().toEqualTypeOf>() + expectTypeOf>().toEqualTypeOf< + Array + >() }) it('should allow inference for a form', () => { @@ -1112,9 +1109,9 @@ describe('FormErrors', () => { Array >() - expectTypeOf>().toEqualTypeOf< - Array - >() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf['selfError']>().toBeAny() + expectTypeOf['subfieldError']>().toBeAny() }) }) diff --git a/packages/form-core/tests/validation/createErrorMap.lib.spec.ts b/packages/form-core/tests/validation/createErrorMap.lib.spec.ts index 3797a263a2..15592cba61 100644 --- a/packages/form-core/tests/validation/createErrorMap.lib.spec.ts +++ b/packages/form-core/tests/validation/createErrorMap.lib.spec.ts @@ -1,4 +1,4 @@ -import { assert, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { createErrorMap } from '../../src/validation/createErrorMap.lib' describe('createErrorMap', () => { @@ -11,22 +11,22 @@ describe('createErrorMap', () => { ])('normalizes a single form error: $form', ({ form, expected }) => { const map = createErrorMap({ form }) - expect(map).toEqual({ form: expected, fields: {} }) + expect(map).toMatchObject({ form: expected, fields: {} }) }) - it('accepts single and multiple field errors without a form error', () => { + it('normalizes initial field validation errors', () => { const map = createErrorMap({ fields: { name: 'Required', age: ['Too young', 'Must be an adult'] }, }) - expect(map).toEqual({ + expect(map).toMatchObject({ form: [], - fields: { name: 'Required', age: ['Too young', 'Must be an adult'] }, + fields: { name: ['Required'], age: ['Too young', 'Must be an adult'] }, }) }) it('allows editing errors without changing the supplied readonly containers', () => { - const issue = { message: 'Invalid', code: 'custom' } + const issue = { message: 'Invalid' } // While `expect(old).not.toBe(new)` reads better, // we need to verify that // 1. It's shallow copied @@ -38,16 +38,16 @@ describe('createErrorMap', () => { const map = createErrorMap(initial) const nameErrors = map.fields.name - assert(Array.isArray(nameErrors)) - nameErrors.push({ message: 'Too short', code: 'length' }) - map.form.push({ message: 'Check the form', code: 'form' }) - map.fields.age = { message: 'Too young', code: 'age' } + expect(nameErrors).toBeInstanceOf(Array) + map.addFieldError('name', { message: 'Too short' }) + map.addFormError({ message: 'Check the form' }) + map.addFieldError('age', { message: 'Too young' }) - expect(map).toEqual({ - form: [issue, { message: 'Check the form', code: 'form' }], + expect(map).toMatchObject({ + form: [issue, { message: 'Check the form' }], fields: { - name: [issue, { message: 'Too short', code: 'length' }], - age: { message: 'Too young', code: 'age' }, + name: [issue, { message: 'Too short' }], + age: [issue, { message: 'Too young' }], }, }) expect(initial).toEqual({ @@ -55,24 +55,4 @@ describe('createErrorMap', () => { fields: { name: [issue], age: issue }, }) }) - - it.each([ - { scenario: 'omitted errors', initial: undefined }, - { scenario: 'an empty error map', initial: {} }, - ])( - 'keeps separately created maps independent with $scenario', - ({ initial }) => { - const first = createErrorMap(initial) - const second = createErrorMap(initial) - - first.form.push({ message: 'Invalid form' }) - first.fields.name = { message: 'Required' } - - expect(first).toEqual({ - form: [{ message: 'Invalid form' }], - fields: { name: { message: 'Required' } }, - }) - expect(second).toEqual({ form: [], fields: {} }) - }, - ) }) diff --git a/packages/preact-form/src/PreactForm/Components.public.ts b/packages/preact-form/src/PreactForm/Components.public.ts index 2554dd80d5..dcaaf8cf18 100644 --- a/packages/preact-form/src/PreactForm/Components.public.ts +++ b/packages/preact-form/src/PreactForm/Components.public.ts @@ -5,12 +5,12 @@ import type { FieldApi, FieldApiOptions, FieldValidators, - FormErrorTypes, FormGroupApi, FormGroupOptions, FormGroupState, FormGroupValidators, FormState, + ScopedErrorTypes, ToFieldError, ToFormGroupErrorTypes, } from '@tanstack/form-core' @@ -80,9 +80,9 @@ export type PreactFieldApi< TFieldValue, TFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, -> = FieldApi & +> = FieldApi & FieldComponentsMatchingType /** @@ -99,15 +99,15 @@ interface PreactSubscribeProps { export type PreactFormSubscribeProps< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TSelected, -> = PreactSubscribeProps, TSelected> +> = PreactSubscribeProps, TSelected> export type PreactFormSubscribeComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, > = ( - props: PreactFormSubscribeProps, + props: PreactFormSubscribeProps, ) => CrossVersionPreactNode export interface PreactFormFieldProps< @@ -121,7 +121,7 @@ export interface PreactFormFieldProps< >, in out TGroupFieldError, in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record>, > extends FieldApiOptions< TFieldData, @@ -130,19 +130,15 @@ export interface PreactFormFieldProps< TFieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes + TFormError > { children: ( fieldApi: PreactFieldApi< TFieldName, TFieldValue, - ToFieldError< - NoInfer, - TGroupFieldError, - TFormErrorTypes - >, + ToFieldError, TGroupFieldError, TFormError>, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ) => CrossVersionPreactNode @@ -150,7 +146,7 @@ export interface PreactFormFieldProps< export type PreactFormFieldComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record>, > = { < @@ -168,7 +164,7 @@ export type PreactFormFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): CrossVersionPreactNode @@ -176,7 +172,7 @@ export type PreactFormFieldComponent< export type PreactFormArrayFieldComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record>, > = { < @@ -194,7 +190,7 @@ export type PreactFormArrayFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): CrossVersionPreactNode @@ -202,29 +198,22 @@ export type PreactFormArrayFieldComponent< export type PreactFormGroupSubscribeProps< TGroupValue, - TGroupErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TSelected, -> = PreactSubscribeProps< - FormGroupState, - TSelected -> +> = PreactSubscribeProps, TSelected> export type PreactFormGroupSubscribeComponent< in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, > = ( - props: PreactFormGroupSubscribeProps< - TGroupValue, - TGroupErrorTypes, - TSelected - >, + props: PreactFormGroupSubscribeProps, ) => CrossVersionPreactNode export interface PreactFormGroupFieldComponent< in out TFormData, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record>, > { < @@ -240,9 +229,9 @@ export interface PreactFormGroupFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['subfieldError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): CrossVersionPreactNode @@ -251,8 +240,8 @@ export interface PreactFormGroupFieldComponent< export type PreactFormGroupArrayFieldComponent< in out TFormData, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record>, > = { < @@ -268,9 +257,9 @@ export type PreactFormGroupArrayFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['subfieldError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): CrossVersionPreactNode @@ -280,31 +269,31 @@ export interface PreactFormGroupApi< in out TFormData, in out TGroupName, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record>, > extends FormGroupApi< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes + TGroupError, + TFormError > { Field: PreactFormGroupFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes, + TGroupError, + TFormError, TFieldComponents > ArrayField: PreactFormGroupArrayFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes, + TGroupError, + TFormError, TFieldComponents > - Subscribe: PreactFormGroupSubscribeComponent + Subscribe: PreactFormGroupSubscribeComponent } export interface PreactFormGroupProps< @@ -312,7 +301,7 @@ export interface PreactFormGroupProps< in out TGroupName, in out TGroupValue, in out TGroupValidators extends FormGroupValidators, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record>, > extends Omit< FormGroupOptions< @@ -320,7 +309,7 @@ export interface PreactFormGroupProps< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, 'form' > { @@ -330,7 +319,7 @@ export interface PreactFormGroupProps< TGroupName, TGroupValue, ToFormGroupErrorTypes>, - TFormErrorTypes, + TFormError, TFieldComponents >, ) => CrossVersionPreactNode @@ -338,7 +327,7 @@ export interface PreactFormGroupProps< export interface PreactFormGroupComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record>, > { < @@ -351,7 +340,7 @@ export interface PreactFormGroupComponent< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes, + TFormError, TFieldComponents >, ): CrossVersionPreactNode @@ -359,25 +348,21 @@ export interface PreactFormGroupComponent< export interface PreactTanStackFormComponents< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record>, > { /** * TODO docs */ - Field: PreactFormFieldComponent + Field: PreactFormFieldComponent ArrayField: PreactFormArrayFieldComponent< TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > - Subscribe: PreactFormSubscribeComponent + Subscribe: PreactFormSubscribeComponent - FormGroup: PreactFormGroupComponent< - TFormData, - TFormErrorTypes, - TFieldComponents - > + FormGroup: PreactFormGroupComponent } diff --git a/packages/preact-form/src/PreactForm/formApiTypes.public.ts b/packages/preact-form/src/PreactForm/formApiTypes.public.ts index 0bfc99d125..aafb22a735 100644 --- a/packages/preact-form/src/PreactForm/formApiTypes.public.ts +++ b/packages/preact-form/src/PreactForm/formApiTypes.public.ts @@ -1,26 +1,22 @@ -import type { AnyFormApi, FormApi, FormErrorTypes } from '@tanstack/form-core' +import type { AnyFormApi, FormApi, ScopedErrorTypes } from '@tanstack/form-core' import type { FunctionComponent } from 'preact/compat' import type { PreactTanStackFormComponents } from './Components.public' import type { AnyPreactFormComponentMap } from '../AppForm/componentMap.public' type ExtendedFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, -> = FormApi & - PreactTanStackFormComponents +> = FormApi & + PreactTanStackFormComponents export type PreactFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TComponents extends AnyPreactFormComponentMap, > = unknown extends TComponents['formComponents'] - ? ExtendedFormApi - : ExtendedFormApi< - TFormData, - TFormErrorTypes, - TComponents['fieldComponents'] - > & + ? ExtendedFormApi + : ExtendedFormApi & TComponents['formComponents'] /** diff --git a/packages/react-form/src/AppForm/ReactAppFormApi.public.tsx b/packages/react-form/src/AppForm/ReactAppFormApi.public.tsx index b31c39e793..8be1198b40 100644 --- a/packages/react-form/src/AppForm/ReactAppFormApi.public.tsx +++ b/packages/react-form/src/AppForm/ReactAppFormApi.public.tsx @@ -1,4 +1,4 @@ -import type { FormErrorTypes, ScopedErrorTypes } from '@tanstack/form-core' +import type { ScopedErrorTypes } from '@tanstack/form-core' import type { ReactFormApi } from '../ReactForm/formApiTypes.public' import type { FunctionComponent, ReactNode } from 'react' import type { AnyReactFormComponentMap } from './componentMap.public' diff --git a/packages/react-form/src/AppForm/createFormHookTypes.public.ts b/packages/react-form/src/AppForm/createFormHookTypes.public.ts index cd52500289..9991322733 100644 --- a/packages/react-form/src/AppForm/createFormHookTypes.public.ts +++ b/packages/react-form/src/AppForm/createFormHookTypes.public.ts @@ -12,7 +12,6 @@ import type { FormOptions, FormOptionsApi, GetFormErrorTypes, - ToFormErrorTypes, } from '@tanstack/form-core' /** diff --git a/packages/react-form/src/ReactForm/Components.public.ts b/packages/react-form/src/ReactForm/Components.public.ts index 1caefa7f30..3afa3a9bd1 100644 --- a/packages/react-form/src/ReactForm/Components.public.ts +++ b/packages/react-form/src/ReactForm/Components.public.ts @@ -5,7 +5,6 @@ import type { FieldApi, FieldApiOptions, FieldValidators, - FormErrorTypes, FormGroupApi, FormGroupOptions, FormGroupState, diff --git a/packages/react-form/src/ReactForm/formApiTypes.public.ts b/packages/react-form/src/ReactForm/formApiTypes.public.ts index 6d5339b3ca..5b0d294e12 100644 --- a/packages/react-form/src/ReactForm/formApiTypes.public.ts +++ b/packages/react-form/src/ReactForm/formApiTypes.public.ts @@ -1,9 +1,4 @@ -import type { - AnyFormApi, - FormApi, - FormErrorTypes, - ScopedErrorTypes, -} from '@tanstack/form-core' +import type { AnyFormApi, FormApi, ScopedErrorTypes } from '@tanstack/form-core' import type { ReactTanStackFormComponents } from './Components.public' import type { AnyReactFormComponentMap, diff --git a/packages/react-form/src/ReactForm/formType.public.ts b/packages/react-form/src/ReactForm/formType.public.ts index dfb8f81ddc..f5f69a10b3 100644 --- a/packages/react-form/src/ReactForm/formType.public.ts +++ b/packages/react-form/src/ReactForm/formType.public.ts @@ -2,7 +2,6 @@ import type { AnyFormOptions, FormOptions, GetFormErrorTypes, - ToFormErrorTypes, } from '@tanstack/form-core' import type { AnyReactFormComponentMap, diff --git a/packages/react-form/src/ReactForm/useForm.public.ts b/packages/react-form/src/ReactForm/useForm.public.ts index 15357d93d1..5d9c13bfac 100644 --- a/packages/react-form/src/ReactForm/useForm.public.ts +++ b/packages/react-form/src/ReactForm/useForm.public.ts @@ -3,7 +3,6 @@ import type { AnyFormOptions, FormOptions, GetFormErrorTypes, - ToFormErrorTypes, } from '@tanstack/form-core' import type { AnyReactFormComponentMap, diff --git a/packages/react-form/tests/FormGroup.test-d.tsx b/packages/react-form/tests/FormGroup.test-d.tsx index 01b14972d8..72a933904e 100644 --- a/packages/react-form/tests/FormGroup.test-d.tsx +++ b/packages/react-form/tests/FormGroup.test-d.tsx @@ -5,8 +5,8 @@ import { useForm } from '../src' import type { DeepKeys, FieldApi, - FormErrorTypes, FormGroupApi, + ScopedErrorTypes, StandardSchemaV1Issue, ValidationIssue, } from '../src' @@ -77,7 +77,7 @@ function FormGroupTypes() { expectTypeOf(group.state.isPristine).toEqualTypeOf() expectTypeOf(group.form.state.values.budget).toEqualTypeOf() expectTypeOf>().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() // @ts-expect-error group state values are scoped to guestDetails group.state.values.budget @@ -166,7 +166,7 @@ function FormGroupSubmitTypes() { { run: guestDetailsSchema, triggers: [] }, { run: guestDetailsSchema, - runOnSubmit: false, + skipOnSubmit: true, triggers: [], }, ]} @@ -177,7 +177,7 @@ function FormGroupSubmitTypes() { }>() expectTypeOf(validatorOutputs[1]).toEqualTypeOf() expectTypeOf>().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() }} onSubmitInvalid={(context) => { @@ -188,7 +188,7 @@ function FormGroupSubmitTypes() { expectTypeOf< FormGroupErrorTypesOf >().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() }} > @@ -355,7 +355,7 @@ function FormGroupWithoutValidatorsTypes() { {(group) => { expectTypeOf>().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() expectTypeOf(group.state.errors).toEqualTypeOf>() return null diff --git a/packages/react-form/tests/createFormHook.test-d.tsx b/packages/react-form/tests/createFormHook.test-d.tsx index 322bd4d6b9..e71a1379b6 100644 --- a/packages/react-form/tests/createFormHook.test-d.tsx +++ b/packages/react-form/tests/createFormHook.test-d.tsx @@ -15,7 +15,7 @@ const { useAppForm } = createFormHook({ defaultFormOptions: { listenersMerge: 'append', errorVisibility: ({ state, fieldState }) => { - expectTypeOf(state.values).toBeUnknown() + expectTypeOf(state.values).toBeAny() expectTypeOf(fieldState.value).toBeAny() return true }, @@ -23,14 +23,14 @@ const { useAppForm } = createFormHook({ { triggers: [], run: ({ value, formApi }) => { - expectTypeOf(value).toBeUnknown() - expectTypeOf(formApi.state.values).toBeUnknown() + expectTypeOf(value).toBeAny() + expectTypeOf(formApi.state.values).toBeAny() }, }, ], onSubmitInvalid: ({ value, formApi }) => { - expectTypeOf(value).toBeUnknown() - expectTypeOf(formApi.state.values).toBeUnknown() + expectTypeOf(value).toBeAny() + expectTypeOf(formApi.state.values).toBeAny() }, }, defaultFieldOptions: { diff --git a/packages/react-form/tests/submit-return.test-d.tsx b/packages/react-form/tests/submit-return.test-d.tsx index 4d8ebf38a4..96ea148b01 100644 --- a/packages/react-form/tests/submit-return.test-d.tsx +++ b/packages/react-form/tests/submit-return.test-d.tsx @@ -28,7 +28,7 @@ describe('submit return', () => { ], onSubmit: ({ validatorOutputs, createValidationError }) => { expectTypeOf(validatorOutputs).toEqualTypeOf< - readonly [{ email: string }, { normalizedEmail: string }] + [{ email: string }, { normalizedEmail: string }] >() return createValidationError({ @@ -64,169 +64,75 @@ describe('submit return', () => { }) describe('formOptions', () => { - it('should allow shared options to omit onSubmit', () => { - const sharedOptionsWithoutSubmit = formOptions({ + it('attaches precise submit handlers to shared options with callbacks', () => { + const sharedOptions = formOptions({ defaultValues: { email: '' }, validators: [], - }) - - type SharedFormWithoutSubmit = ReactFormType< - typeof sharedOptionsWithoutSubmit - > - - function SharedFormChild(props: { form: SharedFormWithoutSubmit }) { - const { form } = props - - return ( - - {(field) => { - expectTypeOf(field.errors).toEqualTypeOf>() - return null - }} - - ) - } - - function ParentWithSubmitInComponent() { - const formWithSyncError = useForm({ - ...sharedOptionsWithoutSubmit, - onSubmit: ({ createValidationError }) => { - if (Math.random() > 0.5) { - return createValidationError({ - fields: { - email: { message: '', fromSubmit: true }, - }, - }) - } - return null + listeners: [ + { + triggers: ['submit'], + run: ({ value, formApi }) => { + expectTypeOf(value).toEqualTypeOf<{ email: string }>() + expectTypeOf(formApi.state.errors).toEqualTypeOf>() + // @ts-expect-error The submit fallback does not erase value types. + formApi.setFieldValue('email', 42) + }, }, - }) + ], + onSubmitInvalid: ({ formApi }) => { + expectTypeOf(formApi.state.values).toEqualTypeOf<{ email: string }>() + expectTypeOf(formApi.state.errors).toEqualTypeOf>() + // @ts-expect-error The submit fallback does not erase field paths. + formApi.setFieldValue('missing', '') + }, + }) - const formWithAsyncError = useForm({ - ...sharedOptionsWithoutSubmit, + function Component() { + const sync = useForm({ + ...sharedOptions, onSubmit: ({ createValidationError }) => - Promise.resolve( - createValidationError({ - fields: { - email: { message: '', fromSubmit: true }, - }, - }), - ), + createValidationError({ + form: { message: '', code: 'sync' as const }, + fields: {}, + }), }) - - const formWithoutError = useForm({ - ...sharedOptionsWithoutSubmit, - onSubmit: () => Promise.resolve(null), + const async = useForm({ + ...sharedOptions, + onSubmit: async ({ createValidationError }) => + createValidationError({ + form: { message: '', code: 'async' as const }, + fields: { email: { message: '', code: 'email' as const } }, + }), }) - const formUnchanged = useForm(sharedOptionsWithoutSubmit) - - expectTypeOf(formWithSyncError).toExtend() - expectTypeOf(formWithAsyncError).toExtend() - expectTypeOf(formWithoutError).toExtend() - expectTypeOf(formUnchanged).toExtend() + const direct = useForm({ defaultValues: { email: '' } }) + + expectTypeOf(sync.state.errors).toEqualTypeOf< + Array<{ message: string; code: 'sync' }> + >() + expectTypeOf(async.state.errors).toEqualTypeOf< + Array<{ message: string; code: 'async' }> + >() + expectTypeOf(direct.state.errors).toEqualTypeOf< + Array + >() return ( - <> - - - - - + + {(field) => { + expectTypeOf(field.errors).toEqualTypeOf< + Array<{ message: string; code: 'email' }> + >() + return null + }} + ) } - void ParentWithSubmitInComponent - }) - - it('should allow options to include onSubmit, strictly matching it', () => { - const sharedOptionsWithSubmit = formOptions({ - defaultValues: { email: '' }, - validators: [], - onSubmit: ({ createValidationError }) => - createValidationError({ - form: { message: '', code: 'form' as const }, - fields: { - email: { message: '', code: 'email' as const }, - }, - }), - }) - - type SharedFormWithSubmit = ReactFormType - - function ParentWithAsyncSubmitInComponent() { - const formWithSyncError = useForm({ - ...sharedOptionsWithSubmit, - onSubmit: ({ createValidationError }) => { - if (Math.random() > 0.5) { - return createValidationError({ - form: { message: '', code: 'form' as const }, - fields: { - email: { message: '', code: 'email' as const }, - }, - }) - } - return null - }, - }) - - const formWithAsyncError = useForm({ - ...sharedOptionsWithSubmit, - onSubmit: ({ createValidationError }) => - Promise.resolve( - createValidationError({ - fields: { - email: { message: '', fromSubmit: true }, - }, - }), - ), - }) - - const formWithoutError = useForm({ - ...sharedOptionsWithSubmit, - onSubmit: () => Promise.resolve(null), - }) - const formUnchanged = useForm(sharedOptionsWithSubmit) - - expectTypeOf(formWithSyncError).toExtend() - expectTypeOf(formWithAsyncError).not.toExtend() - expectTypeOf(formWithoutError).not.toExtend() - expectTypeOf(formUnchanged).toExtend() - } - - void ParentWithAsyncSubmitInComponent - }) - }) - - describe('appFormOptions', () => { - const { appFormOptions, useAppForm } = createFormHook({ - fieldComponents: {}, - formComponents: {}, - }) - - it('preserves registered components through schema-first overloads', () => { - const SubmitButton = () => null - const schema = z.object({ email: z.string() }) - const { appFormOptions: componentFormOptions } = createFormHook({ - fieldComponents: {}, - formComponents: { SubmitButton }, - }) - const strictOptions = componentFormOptions.strictSchema(schema, { - defaultValues: { email: '' }, - }) - const looseOptions = componentFormOptions.looseSchema(schema, { - defaultValues: { email: null }, - }) - - expectTypeOf< - ReactFormType['SubmitButton'] - >().toEqualTypeOf() - expectTypeOf< - ReactFormType['SubmitButton'] - >().toEqualTypeOf() + void Component }) it('should allow shared options to omit onSubmit', () => { - const sharedOptionsWithoutSubmit = appFormOptions({ + const sharedOptionsWithoutSubmit = formOptions({ defaultValues: { email: '' }, validators: [], }) @@ -249,7 +155,7 @@ describe('submit return', () => { } function ParentWithSubmitInComponent() { - const formWithSyncError = useAppForm({ + const formWithSyncError = useForm({ ...sharedOptionsWithoutSubmit, onSubmit: ({ createValidationError }) => { if (Math.random() > 0.5) { @@ -263,7 +169,7 @@ describe('submit return', () => { }, }) - const formWithAsyncError = useAppForm({ + const formWithAsyncError = useForm({ ...sharedOptionsWithoutSubmit, onSubmit: ({ createValidationError }) => Promise.resolve( @@ -275,11 +181,11 @@ describe('submit return', () => { ), }) - const formWithoutError = useAppForm({ + const formWithoutError = useForm({ ...sharedOptionsWithoutSubmit, onSubmit: () => Promise.resolve(null), }) - const formUnchanged = useAppForm(sharedOptionsWithoutSubmit) + const formUnchanged = useForm(sharedOptionsWithoutSubmit) expectTypeOf(formWithSyncError).toExtend() expectTypeOf(formWithAsyncError).toExtend() @@ -299,62 +205,109 @@ describe('submit return', () => { void ParentWithSubmitInComponent }) - it('should allow options to include onSubmit, strictly matching it', () => { - const sharedOptionsWithSubmit = appFormOptions({ - defaultValues: { email: '' }, - validators: [], - onSubmit: ({ createValidationError }) => - createValidationError({ - form: { message: '', code: 'form' as const }, - fields: { - email: { message: '', code: 'email' as const }, - }, - }), + describe('appFormOptions', () => { + const { appFormOptions, useAppForm } = createFormHook({ + fieldComponents: {}, + formComponents: {}, }) - type SharedFormWithSubmit = ReactFormType - - function ParentWithAsyncSubmitInComponent() { - const formWithSyncError = useAppForm({ - ...sharedOptionsWithSubmit, - onSubmit: ({ createValidationError }) => { - if (Math.random() > 0.5) { - return createValidationError({ - form: { message: '', code: 'form' as const }, - fields: { - email: { message: '', code: 'email' as const }, - }, - }) - } - return null - }, + it('preserves registered components through schema-first overloads', () => { + const SubmitButton = () => null + const schema = z.object({ email: z.string() }) + const { appFormOptions: componentFormOptions } = createFormHook({ + fieldComponents: {}, + formComponents: { SubmitButton }, }) - - const formWithAsyncError = useAppForm({ - ...sharedOptionsWithSubmit, - onSubmit: ({ createValidationError }) => - Promise.resolve( - createValidationError({ - fields: { - email: { message: '', fromSubmit: true }, - }, - }), - ), + const strictOptions = componentFormOptions.strictSchema(schema, { + defaultValues: { email: '' }, + }) + const looseOptions = componentFormOptions.looseSchema(schema, { + defaultValues: { email: null }, }) - const formWithoutError = useAppForm({ - ...sharedOptionsWithSubmit, - onSubmit: () => Promise.resolve(null), + expectTypeOf< + ReactFormType['SubmitButton'] + >().toEqualTypeOf() + expectTypeOf< + ReactFormType['SubmitButton'] + >().toEqualTypeOf() + }) + + it('should allow shared options to omit onSubmit', () => { + const sharedOptionsWithoutSubmit = appFormOptions({ + defaultValues: { email: '' }, + validators: [], }) - const formUnchanged = useAppForm(sharedOptionsWithSubmit) - expectTypeOf(formWithSyncError).toExtend() - expectTypeOf(formWithAsyncError).not.toExtend() - expectTypeOf(formWithoutError).not.toExtend() - expectTypeOf(formUnchanged).toExtend() - } + type SharedFormWithoutSubmit = ReactFormType< + typeof sharedOptionsWithoutSubmit + > + + function SharedFormChild(props: { form: SharedFormWithoutSubmit }) { + const { form } = props + + return ( + + {(field) => { + expectTypeOf(field.errors).toEqualTypeOf< + Array + >() + return null + }} + + ) + } + + function ParentWithSubmitInComponent() { + const formWithSyncError = useAppForm({ + ...sharedOptionsWithoutSubmit, + onSubmit: ({ createValidationError }) => { + if (Math.random() > 0.5) { + return createValidationError({ + fields: { + email: { message: '', fromSubmit: true }, + }, + }) + } + return null + }, + }) - void ParentWithAsyncSubmitInComponent + const formWithAsyncError = useAppForm({ + ...sharedOptionsWithoutSubmit, + onSubmit: ({ createValidationError }) => + Promise.resolve( + createValidationError({ + fields: { + email: { message: '', fromSubmit: true }, + }, + }), + ), + }) + + const formWithoutError = useAppForm({ + ...sharedOptionsWithoutSubmit, + onSubmit: () => Promise.resolve(null), + }) + const formUnchanged = useAppForm(sharedOptionsWithoutSubmit) + + expectTypeOf(formWithSyncError).toExtend() + expectTypeOf(formWithAsyncError).toExtend() + expectTypeOf(formWithoutError).toExtend() + expectTypeOf(formUnchanged).toExtend() + + return ( + <> + + + + + + ) + } + + void ParentWithSubmitInComponent + }) }) }) }) From cd80d6f3767b2b1c4bda3d7131079d916f36a539 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:08:30 +0100 Subject: [PATCH 28/43] fix types in core, react-form, lit-form --- .../FormApi/formOptions.public.test-d.ts | 6 +-- .../tests/validation-pipeline.test.ts | 6 +-- packages/form-core/tests/validation.test-d.ts | 6 +-- .../tests/validation/state.public.test-d.ts | 45 +++++++----------- packages/lit-form/src/get-form-type.ts | 22 +++------ .../lit-form/tests/form-controller.spec.ts | 3 -- packages/lit-form/tests/types.spec.ts | 46 ++++++++++++++++++- .../react-form/tests/submit-return.test-d.tsx | 8 +++- 8 files changed, 83 insertions(+), 59 deletions(-) diff --git a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts index 83ec860323..009bf62e87 100644 --- a/packages/form-core/tests/FormApi/formOptions.public.test-d.ts +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -1082,10 +1082,10 @@ describe('formOptions validator inference', () => { expectTypeOf(parseIssues).toEqualTypeOf>() const errors = createErrorMap({ form: ['Please check the form'] }) - errors.fields['users[0].name'] = 'Required' - errors.form.push({ message: 'Invalid' }) + errors.addFieldError('users[0].name', 'Required') + errors.addFormError({ message: 'Invalid' }) // @ts-expect-error Error map fields must belong to the form values. - errors.fields.missing = 'Invalid' + errors.addFieldError('missing', 'Invalid') const issues: ReadonlyArray = [ { message: 'Required', path: ['users', 0, 'name'] }, diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts index 828c037406..bd3460603f 100644 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -184,7 +184,7 @@ describe('runFormValidatorPipeline', () => { { run: ({ createErrorMap }) => { const errors = createErrorMap() - errors.fields.name = 'Name is required' + errors.addFieldError('name', 'Name is required') return errors }, triggers: [], @@ -194,7 +194,7 @@ describe('runFormValidatorPipeline', () => { const results = await runWithContext({ event: 'submit' }) expect(results).toMatchObject([ - { result: { fields: { name: 'Name is required' } } }, + { result: { fields: { name: ['Name is required'] } } }, ]) }) @@ -227,7 +227,7 @@ describe('runFormValidatorPipeline', () => { { run: ({ createErrorMap }) => { const errors = createErrorMap() - errors.fields.name = 'Name is required' + errors.addFieldError('name', 'Name is required') return errors }, triggers: [], diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index 59422bcef3..aaf7a62b0f 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -504,7 +504,7 @@ describe('createErrorMap', () => { run: ({ createErrorMap }) => { const errors = createErrorMap({ form: 'Form error' }) - errors.fields.name = 'Name is required' + errors.addFieldError('name', 'Name is required') // @ts-expect-error Error map fields must use valid form keys. errors.fields.missing = 'Missing' @@ -525,7 +525,7 @@ describe('createErrorMap', () => { run: ({ createErrorMap }) => { const errors = createErrorMap({ form: 'Group error' }) - errors.fields.name = 'Name is required' + errors.addFieldError('name', 'Name is required') // @ts-expect-error Error map fields must use valid group keys. errors.fields.missing = 'Missing' @@ -546,7 +546,7 @@ describe('createErrorMap', () => { run: ({ createErrorMap }) => { const errors = createErrorMap() - errors.fields.name = 'Name is required' + errors.addFieldError('name', 'Name is required') return errors }, diff --git a/packages/form-core/tests/validation/state.public.test-d.ts b/packages/form-core/tests/validation/state.public.test-d.ts index ac9c02a6d8..600c934040 100644 --- a/packages/form-core/tests/validation/state.public.test-d.ts +++ b/packages/form-core/tests/validation/state.public.test-d.ts @@ -19,16 +19,17 @@ describe('CreateErrorMapFn', () => { form: [{ message: 'Required' }] as const, }) - strings.form.push('Too short') - scalarString.form.push('Too short') - readonlyStrings.form.push('Too short') - issues.form.push({ message: 'Too short' }) - scalarIssue.form.push({ message: 'Too short' }) - readonlyIssues.form.push({ message: 'Required' }) - strings.fields.name = 'Required' - strings.fields.age = ['Too young', 'Must be an adult'] - issues.fields.name = { message: 'Required' } - issues.fields.age = [{ message: 'Too young' }] + strings.addFormError('Too short') + scalarString.addFormError('Too short') + readonlyStrings.addFormError('Too short') + issues.addFormError({ message: 'Too short' }) + scalarIssue.addFormError({ message: 'Too short' }) + readonlyIssues.addFormError({ message: 'Required' }) + strings.addFieldError('name', 'Required') + strings.addFieldError('name', 'Required') + strings.addFieldError('age', ['Too young', 'Must be an adult']) + issues.addFieldError('name', { message: 'Required' }) + issues.addFieldError('age', [{ message: 'Too young' }]) }) it('accepts strings and issues when no error item type can be inferred', () => { @@ -39,23 +40,9 @@ describe('CreateErrorMapFn', () => { expectTypeOf(omitted.form).toEqualTypeOf>() expectTypeOf(empty.form).toEqualTypeOf>() expectTypeOf(emptyFields.form).toEqualTypeOf>() - empty.form.push('Required') - omitted.form.push({ message: 'Invalid' }) - emptyFields.fields.name = { message: 'Invalid' } - }) - - it('retains custom issue types inferred from form or field errors', () => { - const issue = { message: 'Invalid name', code: 'name' as const } - const fromForm = createErrorMap({ form: [issue] }) - const fromFields = createErrorMap({ - form: [], - fields: { name: [issue], age: issue }, - }) - - expectTypeOf(fromForm.form).toEqualTypeOf>() - expectTypeOf(fromFields.form).toEqualTypeOf>() - fromForm.fields.name = [issue] - fromFields.form.push(issue) + empty.addFormError('Required') + omitted.addFormError({ message: 'Invalid' }) + emptyFields.addFieldError('name', { message: 'Invalid' }) }) it('rejects invalid errors and field paths', () => { @@ -69,9 +56,9 @@ describe('CreateErrorMapFn', () => { createNameErrorMap({ fields: { name: 'Required', missing: 'Invalid' } }) const map = createNameErrorMap() - map.fields.name = 'Required' + map.addFieldError('name', 'Required') // @ts-expect-error Mutation also checks field paths. - map.fields.missing = 'Invalid' + map.addFieldError('missing', 'Invalid') }) it('rejects schema issue arrays as initial maps', () => { diff --git a/packages/lit-form/src/get-form-type.ts b/packages/lit-form/src/get-form-type.ts index 7528f930eb..80de0b503f 100644 --- a/packages/lit-form/src/get-form-type.ts +++ b/packages/lit-form/src/get-form-type.ts @@ -63,26 +63,18 @@ export type AnyLitFormApi = TanStackFormController export type LitFormType = TOptions extends FormOptions< infer TFormData, - infer TValidatorConfig, + any, infer TValidators, infer TSubmitReturn, any > - ? TanStackFormController< + ? // Keep option-only generics erased so component-level submit handlers remain + // assignable through update(), while preserving the derived public errors. + TanStackFormController< TFormData, - TValidatorConfig, - TValidators, - TSubmitReturn, + any, + any, + any, LitFormTypeErrorTypes > : never - -/** - * Type-only helper for declaring a child element's form property from shared - * `formOptions`. It intentionally has no runtime value. - */ -export function getFormType( - _formOptions: TOptions, -): LitFormType { - return undefined as never -} diff --git a/packages/lit-form/tests/form-controller.spec.ts b/packages/lit-form/tests/form-controller.spec.ts index ce716695c4..af82e6ff92 100644 --- a/packages/lit-form/tests/form-controller.spec.ts +++ b/packages/lit-form/tests/form-controller.spec.ts @@ -6,7 +6,6 @@ import { TanStackFormController, defineFieldGroup, formOptions, - getFormType, } from '../src/index.js' import { defineOnce, mount } from './utils.js' import type { LitFormType } from '../src/index.js' @@ -457,8 +456,6 @@ describe('TanStackFormController', () => { it('derives reusable controller types from formOptions', () => { type ReusableForm = LitFormType - const reusableForm = getFormType(personFormOptions) - expectTypeOf(reusableForm).toEqualTypeOf() class Parent extends LitElement { form = new TanStackFormController(this, { diff --git a/packages/lit-form/tests/types.spec.ts b/packages/lit-form/tests/types.spec.ts index e185a1fbb7..1c931e9dda 100644 --- a/packages/lit-form/tests/types.spec.ts +++ b/packages/lit-form/tests/types.spec.ts @@ -1,10 +1,15 @@ import { describe, expectTypeOf, it } from 'vitest' -import { defineFieldGroup, formOptions } from '../src/index.js' +import { + TanStackFormController, + defineFieldGroup, + formOptions, +} from '../src/index.js' import type { AnyLitFormApi, LitFormType, ValidationIssue, } from '../src/index.js' +import type { ReactiveControllerHost } from 'lit' const options = formOptions({ defaultValues: { @@ -89,3 +94,42 @@ describe('Lit controller types', () => { expectTypeOf(assertReusableFieldTypes).toBeFunction() }) }) + +function assertSharedSubmitTypes(host: ReactiveControllerHost) { + const sharedOptions = formOptions({ + defaultValues: { email: '' }, + validators: [], + }) + type SharedForm = LitFormType + const acceptSharedForm = (form: SharedForm) => form + + const syncForm = new TanStackFormController(host, { + ...sharedOptions, + onSubmit: ({ createValidationError }) => + createValidationError({ + fields: { email: { message: 'Invalid email', fromSubmit: true } }, + }), + }) + const asyncForm = new TanStackFormController(host, { + ...sharedOptions, + onSubmit: async ({ createValidationError }) => + createValidationError({ + fields: { email: { message: 'Invalid email', fromSubmit: true } }, + }), + }) + const successForm = new TanStackFormController(host, { + ...sharedOptions, + onSubmit: () => Promise.resolve(null), + }) + acceptSharedForm(syncForm) + acceptSharedForm(asyncForm) + acceptSharedForm(successForm) + acceptSharedForm(new TanStackFormController(host, sharedOptions)) + + syncForm.field({ name: 'email' }, (field) => { + expectTypeOf(field.errors[0]!.fromSubmit).toEqualTypeOf() + return null + }) +} + +expectTypeOf(assertSharedSubmitTypes).toBeFunction() diff --git a/packages/react-form/tests/submit-return.test-d.tsx b/packages/react-form/tests/submit-return.test-d.tsx index 96ea148b01..ddb41f5fec 100644 --- a/packages/react-form/tests/submit-return.test-d.tsx +++ b/packages/react-form/tests/submit-return.test-d.tsx @@ -73,7 +73,9 @@ describe('submit return', () => { triggers: ['submit'], run: ({ value, formApi }) => { expectTypeOf(value).toEqualTypeOf<{ email: string }>() - expectTypeOf(formApi.state.errors).toEqualTypeOf>() + expectTypeOf(formApi.state.errors).toEqualTypeOf< + Array + >() // @ts-expect-error The submit fallback does not erase value types. formApi.setFieldValue('email', 42) }, @@ -81,7 +83,9 @@ describe('submit return', () => { ], onSubmitInvalid: ({ formApi }) => { expectTypeOf(formApi.state.values).toEqualTypeOf<{ email: string }>() - expectTypeOf(formApi.state.errors).toEqualTypeOf>() + expectTypeOf(formApi.state.errors).toEqualTypeOf< + Array + >() // @ts-expect-error The submit fallback does not erase field paths. formApi.setFieldValue('missing', '') }, From e9eada46e0693c14022de336bd5ce09529135d25 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:16:44 +0100 Subject: [PATCH 29/43] fix: fix angular-form types --- packages/angular-form/src/form-group.ts | 26 +++++----- packages/angular-form/src/form-type.ts | 30 ++++++------ packages/angular-form/src/inject-form.ts | 25 +++++++--- packages/angular-form/src/inject-selector.ts | 14 +++--- packages/angular-form/src/injectable.ts | 4 +- packages/angular-form/src/tanstack-field.ts | 47 +++++++------------ .../form-core/src/FormApi/FormApi.public.ts | 2 +- .../src/FormGroupApi/FormGroupApi.public.ts | 33 +++++++++---- 8 files changed, 96 insertions(+), 85 deletions(-) diff --git a/packages/angular-form/src/form-group.ts b/packages/angular-form/src/form-group.ts index 46aad0f9f0..808ebc4bd9 100644 --- a/packages/angular-form/src/form-group.ts +++ b/packages/angular-form/src/form-group.ts @@ -9,13 +9,13 @@ import { InternalFormGroupApi } from '@tanstack/form-core/internals' import type { DeepKeys, DeepValue, - FormGroupOptions, + FormApi, + FormGroupOnSubmitInvalidFn, FormGroupSubmitFn, FormGroupValidators, - FormValidators, - ToFormErrorTypes, + ScopedErrorTypes, + ToFormGroupErrorTypes, } from '@tanstack/form-core' -import type { InternalFormApi } from '@tanstack/form-core/internals' @Directive({ selector: '[tanstackFormGroup]', @@ -27,11 +27,9 @@ export class TanStackFormGroup< const TGroupName extends DeepKeys, TGroupValue extends DeepValue, const TGroupValidators extends FormGroupValidators, - const TFormValidators extends FormValidators, - TSubmitReturn, + TFormError extends ScopedErrorTypes, > { - tanstackFormGroup = - input.required>() + tanstackFormGroup = input.required>() name = input.required() validators = input>() onSubmit = @@ -41,18 +39,18 @@ export class TanStackFormGroup< TGroupName, TGroupValue, TGroupValidators, - ToFormErrorTypes + TFormError > >() onSubmitInvalid = input< - FormGroupOptions< + FormGroupOnSubmitInvalidFn< TFormData, TGroupName, TGroupValue, - TGroupValidators, - ToFormErrorTypes - >['onSubmitInvalid'] + NoInfer>, + TFormError + > >() private readonly changeDetector = inject(ChangeDetectorRef) @@ -61,7 +59,7 @@ export class TanStackFormGroup< TGroupName, TGroupValue, TGroupValidators, - ToFormErrorTypes + TFormError > | null = null private getOptions() { diff --git a/packages/angular-form/src/form-type.ts b/packages/angular-form/src/form-type.ts index 0cb7c8f4c7..9c8fb85fcd 100644 --- a/packages/angular-form/src/form-type.ts +++ b/packages/angular-form/src/form-type.ts @@ -1,14 +1,17 @@ -import type { AnyFormOptions, FormOptions } from '@tanstack/form-core' import type { - AnyInternalFormApi, - InternalFormApi, -} from '@tanstack/form-core/internals' + AnyFormApi, + AnyFormOptions, + FormApi, + FormOptions, + GetFormErrorTypes, +} from '@tanstack/form-core' // Reusable formOptions often omit onSubmit so a component can provide it later. // Keep that unresolved submit return flexible when the form is passed to a child. -type AngularFormTypeSubmitReturn = unknown extends TSubmitReturn - ? any - : TSubmitReturn +type AngularFormTypeErrorTypes = + unknown extends TSubmitReturn + ? any + : GetFormErrorTypes /** * An Angular form API whose form data, validator, and submit types are erased. @@ -30,7 +33,7 @@ type AngularFormTypeSubmitReturn = unknown extends TSubmitReturn * } * ``` */ -export type AnyAngularFormApi = AnyInternalFormApi +export type AnyAngularFormApi = AnyFormApi /** * Derives the Angular form API type represented by a reusable options object. @@ -57,13 +60,10 @@ export type AnyAngularFormApi = AnyInternalFormApi export type AngularFormType = TOptions extends FormOptions< infer TFormData, - infer TFormValidators, + any, + infer TValidators, infer TSubmitReturn, - unknown + any > - ? InternalFormApi< - TFormData, - TFormValidators, - AngularFormTypeSubmitReturn - > + ? FormApi> : never diff --git a/packages/angular-form/src/inject-form.ts b/packages/angular-form/src/inject-form.ts index 10a98532f5..63a3186f53 100644 --- a/packages/angular-form/src/inject-form.ts +++ b/packages/angular-form/src/inject-form.ts @@ -1,7 +1,11 @@ import { ChangeDetectorRef, DestroyRef, effect, inject } from '@angular/core' import { injectSelector } from '@tanstack/angular-store' import { InternalFormApi } from '@tanstack/form-core/internals' -import type { FormOptions, FormValidators } from '@tanstack/form-core' +import type { + FormApi, + FormOptions, + GetFormErrorTypes, +} from '@tanstack/form-core' /** * Creates and mounts a form in the current Angular injection context. @@ -37,12 +41,19 @@ import type { FormOptions, FormValidators } from '@tanstack/form-core' */ export function injectForm< TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, ->(options: FormOptions) { - const form = new InternalFormApi( - options, - ) +>( + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, +): FormApi> { + const form = new InternalFormApi(options) const destroyRef = inject(DestroyRef) const changeDetector = inject(ChangeDetectorRef, { optional: true }) const state = injectSelector(form.atom) @@ -58,5 +69,5 @@ export function injectForm< changeDetector?.markForCheck() }) - return form + return form as never } diff --git a/packages/angular-form/src/inject-selector.ts b/packages/angular-form/src/inject-selector.ts index 3e9dbff592..853b8da1f8 100644 --- a/packages/angular-form/src/inject-selector.ts +++ b/packages/angular-form/src/inject-selector.ts @@ -1,18 +1,18 @@ import { injectSelector as injectAngularSelector } from '@tanstack/angular-store' import type { Signal } from '@angular/core' -import type { FormApi, FormErrorTypes, FormState } from '@tanstack/form-core' +import type { FormApi, FormState, ScopedErrorTypes } from '@tanstack/form-core' import type { InjectSelectorOptions } from '@tanstack/angular-store' /** Selects form state as an Angular signal. */ export function injectSelector< TFormData, - TFormErrorTypes extends FormErrorTypes, - TSelected = NoInfer>, + TFormError extends ScopedErrorTypes, + const TSelected = NoInfer>, >( - form: FormApi, - selector: ( - state: NoInfer>, - ) => TSelected = (state) => state as unknown as TSelected, + form: FormApi, + selector: (state: NoInfer>) => TSelected = ( + state, + ) => state as never, options?: InjectSelectorOptions, ): Signal { return injectAngularSelector(form.atom, selector, options) diff --git a/packages/angular-form/src/injectable.ts b/packages/angular-form/src/injectable.ts index f93a8158dc..32545d0854 100644 --- a/packages/angular-form/src/injectable.ts +++ b/packages/angular-form/src/injectable.ts @@ -1,5 +1,5 @@ import { Injectable, inject, signal } from '@angular/core' -import type { FieldApi, FormErrorTypes } from '@tanstack/form-core' +import type { FieldApi, ScopedErrorTypes } from '@tanstack/form-core' @Injectable({ providedIn: null }) export class TanStackFieldInjectable< @@ -7,7 +7,7 @@ export class TanStackFieldInjectable< TFieldName = any, TFieldError = any, TFormData = any, - TFormErrorTypes extends FormErrorTypes = any, + TFormErrorTypes extends ScopedErrorTypes = any, > { _api = signal< FieldApi diff --git a/packages/angular-form/src/tanstack-field.ts b/packages/angular-form/src/tanstack-field.ts index 7a2eb0fcd0..da994c864d 100644 --- a/packages/angular-form/src/tanstack-field.ts +++ b/packages/angular-form/src/tanstack-field.ts @@ -8,15 +8,16 @@ import { } from '@angular/core' import { InternalFormGroupApi } from '@tanstack/form-core/internals' import type { + AnyFormApi, DeepKeys, DeepValue, - ErrorVisibility, + ErrorVisibilityFn, FieldApi, FieldListeners, FieldValidators, - FormValidators, + FormApi, + ScopedErrorTypes, ToFieldError, - ToFormErrorTypes, ToFormGroupErrorTypes, } from '@tanstack/form-core' import type { @@ -24,23 +25,22 @@ import type { AnyInternalFieldApi, AnyInternalFormApi, InternalBaseFieldMeta, - InternalFormApi, } from '@tanstack/form-core/internals' export type AngularFieldSource = - AnyInternalFormApi | InternalFormGroupApi + AnyFormApi | InternalFormGroupApi export type AngularFieldData = TSource extends InternalFormGroupApi ? TGroupValue - : TSource extends InternalFormApi + : TSource extends FormApi ? TFormData : never type AngularParentFormData = TSource extends InternalFormGroupApi ? TFormData - : TSource extends InternalFormApi + : TSource extends FormApi ? TFormData : never @@ -53,12 +53,8 @@ type AngularSourceFormErrorTypes = infer TFormErrorTypes > ? TFormErrorTypes - : TSource extends InternalFormApi< - any, - infer TFormValidators, - infer TSubmitReturn - > - ? ToFormErrorTypes + : TSource extends FormApi + ? TFormError : never type AngularSourceGroupFieldError = @@ -69,7 +65,7 @@ type AngularSourceGroupFieldError = infer TGroupValidators, any > - ? ToFormGroupErrorTypes['fieldError'] + ? ToFormGroupErrorTypes['selfError'] : never type AngularSourceFieldApi< @@ -98,18 +94,13 @@ export type AngularFieldApi< TFieldName extends DeepKeys, TFieldValue extends DeepValue, TFieldValidators extends FieldValidators, - TFormValidators extends FormValidators, - TSubmitReturn, + TFormError extends ScopedErrorTypes, > = FieldApi< TFieldName, TFieldValue, - ToFieldError< - TFieldValidators, - never, - ToFormErrorTypes - >, + ToFieldError, TFormData, - ToFormErrorTypes + TFormError > @Directive() @@ -142,13 +133,7 @@ abstract class TanStackFieldBase< > > >() - errorVisibility = - input< - ErrorVisibility< - AngularParentFormData, - AngularSourceFormErrorTypes - > - >() + errorVisibility = input>>() errorBoundary = input() protected abstract readonly isArrayField: boolean @@ -177,7 +162,9 @@ abstract class TanStackFieldBase< private getForm(): AnyInternalFormApi { const source = this.getSource() - return source instanceof InternalFormGroupApi ? source.form : source + return ( + source instanceof InternalFormGroupApi ? source.form : source + ) as AnyInternalFormApi } private getFieldApi() { diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index 81049c8ce9..89c019f23f 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -242,7 +242,7 @@ export interface FormApi< * Subscribe to this atom to observe state changes. For an imperative read, * use `state`. */ - atom: ReadonlyAtom> + atom: ReadonlyAtom> /** Current values, validation status, and submission metadata. */ readonly state: FormState /** The current baseline values used by `reset()` and `isDefaultValue`. */ diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts index 2ce76ab83f..d2865a0cc1 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts @@ -8,6 +8,7 @@ import type { ToFormGroupErrorTypes, ToFormGroupOutputs, } from '../validation.public' +import type { Awaitable } from '../types.public' /** * Context passed to a form group's `onSubmit` after group validation succeeds. @@ -114,6 +115,22 @@ export type FormGroupSubmitFn< >, ) => void | Promise +export type FormGroupOnSubmitInvalidFn< + TFormData, + TGroupName, + TGroupValue, + TGroupError extends ScopedErrorTypes, + TFormError extends ScopedErrorTypes, +> = ( + context: FormGroupSubmitInvalidContext< + TFormData, + TGroupName, + TGroupValue, + TGroupError, + TFormError + >, +) => Awaitable + export interface FormGroupOptions< in out TFormData, in out TGroupName, @@ -161,15 +178,13 @@ export interface FormGroupOptions< * } * ``` */ - onSubmitInvalid?: ( - context: FormGroupSubmitInvalidContext< - TFormData, - TGroupName, - TGroupValue, - NoInfer>, - TFormError - >, - ) => void | Promise + onSubmitInvalid?: FormGroupOnSubmitInvalidFn< + TFormData, + TGroupName, + TGroupValue, + NoInfer>, + TFormError + > } export interface FormGroupState< From 6c6d983ac02e95f03aa6e838241334a153f8d3e3 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:34:20 +0100 Subject: [PATCH 30/43] fix: solve preact-form types --- .../src/FormGroupApi/FormGroupApi.public.ts | 8 +- .../src/AppForm/PreactAppFormApi.public.tsx | 6 +- .../src/AppForm/createFormHook.public.ts | 4 +- .../src/AppForm/createFormHookTypes.public.ts | 16 ++- .../src/AppForm/initializeAppForm.lib.ts | 3 +- .../src/FieldGroup/FieldGroupApi.public.ts | 13 +- .../src/PreactForm/PreactFormApi.lib.tsx | 12 +- .../src/PreactForm/formType.public.ts | 14 +-- .../src/PreactForm/useField.lib.ts | 3 +- .../src/PreactForm/useForm.public.ts | 19 ++- .../preact-form/tests/FormGroup.test-d.tsx | 13 +- .../tests/createFormHook.test-d.tsx | 2 +- .../tests/submit-return.test-d.tsx | 118 +----------------- 13 files changed, 66 insertions(+), 165 deletions(-) diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts index d2865a0cc1..7cfa519a51 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts @@ -4,6 +4,7 @@ import type { ScopedErrorTypes } from '../validation/state.public' import type { ClientSideEvents } from '../events.public' import type { FormGroupValidateResult, + FormGroupValidator, FormGroupValidators, ToFormGroupErrorTypes, ToFormGroupOutputs, @@ -140,7 +141,12 @@ export interface FormGroupOptions< > { form: FormApi name: TGroupName - validators?: TGroupValidators + validators?: TGroupValidators & { + [K in keyof TGroupValidators]: Record< + Exclude>, + never + > + } /** * Called after group submission validation succeeds. The callback is awaited * before submission finishes. diff --git a/packages/preact-form/src/AppForm/PreactAppFormApi.public.tsx b/packages/preact-form/src/AppForm/PreactAppFormApi.public.tsx index 13051f1e74..da5cfabb5b 100644 --- a/packages/preact-form/src/AppForm/PreactAppFormApi.public.tsx +++ b/packages/preact-form/src/AppForm/PreactAppFormApi.public.tsx @@ -1,8 +1,8 @@ -import type { FormErrorTypes } from '@tanstack/form-core' import type { PreactFormApi } from '../PreactForm/formApiTypes.public' import type { FunctionComponent } from 'preact/compat' import type { CrossVersionPreactNode } from '../preactTypes.public' import type { AnyPreactFormComponentMap } from './componentMap.public' +import type { ScopedErrorTypes } from '@tanstack/form-core' export type AppFormComponent = FunctionComponent<{ children: Exclude> @@ -10,8 +10,8 @@ export type AppFormComponent = FunctionComponent<{ export type PreactAppFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TComponents extends AnyPreactFormComponentMap, -> = PreactFormApi & { +> = PreactFormApi & { AppForm: AppFormComponent } diff --git a/packages/preact-form/src/AppForm/createFormHook.public.ts b/packages/preact-form/src/AppForm/createFormHook.public.ts index 038324b03e..e3ce9c6e7b 100644 --- a/packages/preact-form/src/AppForm/createFormHook.public.ts +++ b/packages/preact-form/src/AppForm/createFormHook.public.ts @@ -9,7 +9,7 @@ import type { UseAppFormHook, } from './createFormHookTypes.public' import type { FunctionComponent } from 'preact/compat' -import type { FormOptions } from '@tanstack/form-core' +import type { AnyFormOptions } from '@tanstack/form-core' export function createFormHook< const TFormComponents extends Record>, @@ -22,7 +22,7 @@ export function createFormHook< }> { const initializeAppForm = createAppFormInitializer(createOptions) - function useExtendedForm(hookOptions: FormOptions) { + function useExtendedForm(hookOptions: AnyFormOptions) { const form = useInternalForm(hookOptions, initializeAppForm) return form } diff --git a/packages/preact-form/src/AppForm/createFormHookTypes.public.ts b/packages/preact-form/src/AppForm/createFormHookTypes.public.ts index fcbc87e8d6..c137ba177b 100644 --- a/packages/preact-form/src/AppForm/createFormHookTypes.public.ts +++ b/packages/preact-form/src/AppForm/createFormHookTypes.public.ts @@ -11,8 +11,7 @@ import type { DefaultFormOptions, FormOptions, FormOptionsApi, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' /** @@ -99,13 +98,20 @@ export type UseAppFormHook< in out TComponents extends AnyPreactFormComponentMap, > = < TFormData, - TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( - options: FormOptions, + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, ) => PreactAppFormApi< TFormData, - ToFormErrorTypes, + GetFormErrorTypes, TComponents > diff --git a/packages/preact-form/src/AppForm/initializeAppForm.lib.ts b/packages/preact-form/src/AppForm/initializeAppForm.lib.ts index 82a3842e29..1e415ce7d4 100644 --- a/packages/preact-form/src/AppForm/initializeAppForm.lib.ts +++ b/packages/preact-form/src/AppForm/initializeAppForm.lib.ts @@ -1,6 +1,7 @@ import { InternalFormApi } from '@tanstack/form-core/internals' import { attachPreactAppFormComponents } from './Components.lib' import type { + AnyFormOptions, DefaultFieldOptions, DefaultFormGroupOptions, DefaultFormOptions, @@ -20,7 +21,7 @@ interface AnyCreateFormHookOptions { export function createAppFormInitializer( createOptions: AnyCreateFormHookOptions, -): (options: FormOptions) => InternalPreactFormApi { +): (options: AnyFormOptions) => InternalPreactFormApi { const hasDefaultOptions = createOptions.defaultFormOptions || createOptions.defaultFieldOptions || diff --git a/packages/preact-form/src/FieldGroup/FieldGroupApi.public.ts b/packages/preact-form/src/FieldGroup/FieldGroupApi.public.ts index 35bae9152b..b3449ae234 100644 --- a/packages/preact-form/src/FieldGroup/FieldGroupApi.public.ts +++ b/packages/preact-form/src/FieldGroup/FieldGroupApi.public.ts @@ -5,8 +5,8 @@ import type { FieldValidators, FormApiArrayMethods, FormApiFieldMethods, - FormErrorTypes, FormState, + ScopedErrorTypes, ValidationIssue, } from '@tanstack/form-core' import type { FunctionComponent } from 'preact/compat' @@ -17,6 +17,9 @@ import type { } from '../PreactForm/Components.public' import type { ReadonlyAtom } from '@tanstack/preact-store' +// TODO what's going on with these types? It seems like it loses basically all form info. +// Is that intentional? + export interface FieldGroupFieldComponent< in out TFieldData, in out TFieldComponents extends Record>, @@ -33,7 +36,7 @@ export interface FieldGroupFieldComponent< >, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents >, ): CrossVersionPreactNode @@ -55,7 +58,7 @@ export interface FieldGroupArrayFieldComponent< >, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents >, ): CrossVersionPreactNode @@ -63,7 +66,7 @@ export interface FieldGroupArrayFieldComponent< export type FieldGroupSubscribeProps = PreactFormSubscribeProps< unknown, - FormErrorTypes, + ScopedErrorTypes, TSelected > @@ -88,4 +91,4 @@ export type AnyFieldGroupApi = FieldGroupApi< Record> > -export type FieldGroupFormState = FormState +export type FieldGroupFormState = FormState diff --git a/packages/preact-form/src/PreactForm/PreactFormApi.lib.tsx b/packages/preact-form/src/PreactForm/PreactFormApi.lib.tsx index a869321d5d..3a219cd85c 100644 --- a/packages/preact-form/src/PreactForm/PreactFormApi.lib.tsx +++ b/packages/preact-form/src/PreactForm/PreactFormApi.lib.tsx @@ -3,7 +3,7 @@ import * as Preact from 'preact/compat' import { useEffect, useRef } from 'preact/compat' import { attachPreactFormComponents } from './Components.lib' import type { AnyInternalFormApi } from '@tanstack/form-core/internals' -import type { FormOptions } from '@tanstack/form-core' +import type { AnyFormOptions } from '@tanstack/form-core' import type { PreactTanStackFormComponents } from './Components.public' const usePreactId = @@ -13,9 +13,7 @@ const usePreactId = export interface InternalPreactFormApi extends AnyInternalFormApi, PreactTanStackFormComponents {} -export function initializeForm( - options: FormOptions, -): InternalPreactFormApi { +export function initializeForm(options: AnyFormOptions): InternalPreactFormApi { const form = new InternalFormApi(options) const preactFormApi = attachPreactFormComponents(form, null) @@ -24,10 +22,8 @@ export function initializeForm( } export function useInternalForm( - options: FormOptions, - initializeFn: ( - options: FormOptions, - ) => InternalPreactFormApi, + options: AnyFormOptions, + initializeFn: (options: AnyFormOptions) => InternalPreactFormApi, ) { const preactFormId = usePreactId() const resolvedOptions = diff --git a/packages/preact-form/src/PreactForm/formType.public.ts b/packages/preact-form/src/PreactForm/formType.public.ts index 76f67c04d5..47ceaac126 100644 --- a/packages/preact-form/src/PreactForm/formType.public.ts +++ b/packages/preact-form/src/PreactForm/formType.public.ts @@ -1,8 +1,7 @@ import type { AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' import type { AnyPreactFormComponentMap, @@ -16,12 +15,10 @@ import type { PreactFormApi } from './formApiTypes.public' // early can break passing forms as props to child components. // This must remain `any` so that `form={form}` remains working. There are type tests for it, so feel // free to tinker. -type PreactFormTypeErrorTypes< - TFormValidators extends FormValidators, - TSubmitReturn, -> = unknown extends TSubmitReturn - ? any - : ToFormErrorTypes +type PreactFormTypeErrorTypes = + unknown extends TSubmitReturn + ? any + : GetFormErrorTypes /** * Derives the Preact form API type represented by a reusable options object. @@ -55,6 +52,7 @@ type PreactFormTypeErrorTypes< export type PreactFormType = TOptions extends FormOptions< infer TFormData, + any, infer TFormValidators, infer TSubmitReturn, infer TComponents diff --git a/packages/preact-form/src/PreactForm/useField.lib.ts b/packages/preact-form/src/PreactForm/useField.lib.ts index b649fa347b..f6bbde009f 100644 --- a/packages/preact-form/src/PreactForm/useField.lib.ts +++ b/packages/preact-form/src/PreactForm/useField.lib.ts @@ -3,6 +3,7 @@ import { useSelector } from '@tanstack/preact-store' import type { FunctionComponent } from 'preact/compat' import type { AnyInternalFieldApi, + AnyInternalFormApi, InternalFormApi, } from '@tanstack/form-core/internals' import type { PreactFormFieldProps } from './Components.public' @@ -17,7 +18,7 @@ interface InternalFieldProps extends PreactFormFieldProps< any, any > { - form: InternalFormApi + form: AnyInternalFormApi } export function useField( diff --git a/packages/preact-form/src/PreactForm/useForm.public.ts b/packages/preact-form/src/PreactForm/useForm.public.ts index cab79a8058..75c0043b26 100644 --- a/packages/preact-form/src/PreactForm/useForm.public.ts +++ b/packages/preact-form/src/PreactForm/useForm.public.ts @@ -1,8 +1,8 @@ import { initializeForm, useInternalForm } from './PreactFormApi.lib' import type { + AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' import type { AnyPreactFormComponentMap, @@ -13,17 +13,24 @@ import type { PreactFormApi } from './formApiTypes.public' export type UseFormHook = < TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( - options: FormOptions, + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, ) => PreactFormApi< TFormData, - ToFormErrorTypes, + GetFormErrorTypes, TComponents > -function useFormHook(options: FormOptions) { +function useFormHook(options: AnyFormOptions) { const form = useInternalForm(options, initializeForm) return form } diff --git a/packages/preact-form/tests/FormGroup.test-d.tsx b/packages/preact-form/tests/FormGroup.test-d.tsx index a7d27188d3..e543e494bb 100644 --- a/packages/preact-form/tests/FormGroup.test-d.tsx +++ b/packages/preact-form/tests/FormGroup.test-d.tsx @@ -1,12 +1,11 @@ -import Preact from 'preact/compat' import { expectTypeOf } from 'vitest' import { z } from 'zod' import { useForm } from '../src' import type { DeepKeys, FieldApi, - FormErrorTypes, FormGroupApi, + ScopedErrorTypes, StandardSchemaV1Issue, ValidationIssue, } from '../src' @@ -77,7 +76,7 @@ function FormGroupTypes() { expectTypeOf(group.state.isPristine).toEqualTypeOf() expectTypeOf(group.form.state.values.budget).toEqualTypeOf() expectTypeOf>().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() // @ts-expect-error group state values are scoped to guestDetails group.state.values.budget @@ -166,7 +165,7 @@ function FormGroupSubmitTypes() { { run: guestDetailsSchema, triggers: [] }, { run: guestDetailsSchema, - runOnSubmit: false, + skipOnSubmit: true, triggers: [], }, ]} @@ -177,7 +176,7 @@ function FormGroupSubmitTypes() { }>() expectTypeOf(validatorOutputs[1]).toEqualTypeOf() expectTypeOf>().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() }} onSubmitInvalid={(context) => { @@ -188,7 +187,7 @@ function FormGroupSubmitTypes() { expectTypeOf< FormGroupErrorTypesOf >().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() }} > @@ -355,7 +354,7 @@ function FormGroupWithoutValidatorsTypes() { {(group) => { expectTypeOf>().toEqualTypeOf< - FormErrorTypes + ScopedErrorTypes >() expectTypeOf(group.state.errors).toEqualTypeOf>() return null diff --git a/packages/preact-form/tests/createFormHook.test-d.tsx b/packages/preact-form/tests/createFormHook.test-d.tsx index 88bfd5f8ca..dbb99e51b6 100644 --- a/packages/preact-form/tests/createFormHook.test-d.tsx +++ b/packages/preact-form/tests/createFormHook.test-d.tsx @@ -11,7 +11,7 @@ const { useAppForm } = createFormHook({ { triggers: [], run: ({ value }) => { - expectTypeOf(value).toBeUnknown() + expectTypeOf(value).toBeAny() }, }, ], diff --git a/packages/preact-form/tests/submit-return.test-d.tsx b/packages/preact-form/tests/submit-return.test-d.tsx index 52b1e9d2c5..e0c9792a93 100644 --- a/packages/preact-form/tests/submit-return.test-d.tsx +++ b/packages/preact-form/tests/submit-return.test-d.tsx @@ -28,7 +28,7 @@ describe('submit return', () => { ], onSubmit: ({ validatorOutputs, createValidationError }) => { expectTypeOf(validatorOutputs).toEqualTypeOf< - readonly [{ email: string }, { normalizedEmail: string }] + [{ email: string }, { normalizedEmail: string }] >() return createValidationError({ @@ -137,64 +137,6 @@ describe('submit return', () => { void ParentWithSubmitInComponent }) - - it('should allow options to include onSubmit, strictly matching it', () => { - const sharedOptionsWithSubmit = formOptions({ - defaultValues: { email: '' }, - validators: [], - onSubmit: ({ createValidationError }) => - createValidationError({ - form: { message: '', code: 'form' as const }, - fields: { - email: { message: '', code: 'email' as const }, - }, - }), - }) - - type SharedFormWithSubmit = PreactFormType - - function ParentWithAsyncSubmitInComponent() { - const formWithSyncError = useForm({ - ...sharedOptionsWithSubmit, - onSubmit: ({ createValidationError }) => { - if (Math.random() > 0.5) { - return createValidationError({ - form: { message: '', code: 'form' as const }, - fields: { - email: { message: '', code: 'email' as const }, - }, - }) - } - return null - }, - }) - - const formWithAsyncError = useForm({ - ...sharedOptionsWithSubmit, - onSubmit: ({ createValidationError }) => - Promise.resolve( - createValidationError({ - fields: { - email: { message: '', fromSubmit: true }, - }, - }), - ), - }) - - const formWithoutError = useForm({ - ...sharedOptionsWithSubmit, - onSubmit: () => Promise.resolve(null), - }) - const formUnchanged = useForm(sharedOptionsWithSubmit) - - expectTypeOf(formWithSyncError).toExtend() - expectTypeOf(formWithAsyncError).not.toExtend() - expectTypeOf(formWithoutError).not.toExtend() - expectTypeOf(formUnchanged).toExtend() - } - - void ParentWithAsyncSubmitInComponent - }) }) describe('appFormOptions', () => { @@ -276,63 +218,5 @@ describe('submit return', () => { void ParentWithSubmitInComponent }) - - it('should allow options to include onSubmit, strictly matching it', () => { - const sharedOptionsWithSubmit = appFormOptions({ - defaultValues: { email: '' }, - validators: [], - onSubmit: ({ createValidationError }) => - createValidationError({ - form: { message: '', code: 'form' as const }, - fields: { - email: { message: '', code: 'email' as const }, - }, - }), - }) - - type SharedFormWithSubmit = PreactFormType - - function ParentWithAsyncSubmitInComponent() { - const formWithSyncError = useAppForm({ - ...sharedOptionsWithSubmit, - onSubmit: ({ createValidationError }) => { - if (Math.random() > 0.5) { - return createValidationError({ - form: { message: '', code: 'form' as const }, - fields: { - email: { message: '', code: 'email' as const }, - }, - }) - } - return null - }, - }) - - const formWithAsyncError = useAppForm({ - ...sharedOptionsWithSubmit, - onSubmit: ({ createValidationError }) => - Promise.resolve( - createValidationError({ - fields: { - email: { message: '', fromSubmit: true }, - }, - }), - ), - }) - - const formWithoutError = useAppForm({ - ...sharedOptionsWithSubmit, - onSubmit: () => Promise.resolve(null), - }) - const formUnchanged = useAppForm(sharedOptionsWithSubmit) - - expectTypeOf(formWithSyncError).toExtend() - expectTypeOf(formWithAsyncError).not.toExtend() - expectTypeOf(formWithoutError).not.toExtend() - expectTypeOf(formUnchanged).toExtend() - } - - void ParentWithAsyncSubmitInComponent - }) }) }) From 87b2f7c16e043476d808f4ac6278262d73a6c897 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:49:19 +0100 Subject: [PATCH 31/43] fix: resolve lit-form and svelte-form --- .../lit-form/src/tanstack-form-controller.ts | 2 +- .../src/AppForm/SvelteAppFormApi.public.ts | 6 +- .../src/AppForm/createFormHook.public.ts | 4 +- .../src/AppForm/createFormHookTypes.public.ts | 11 ++- .../src/AppForm/initializeAppForm.lib.ts | 3 +- packages/svelte-form/src/Components.public.ts | 99 +++++++++---------- packages/svelte-form/src/Field.svelte | 44 +++++---- .../src/FieldGroup/FieldGroupApi.public.ts | 10 +- packages/svelte-form/src/FormGroup.svelte | 8 +- packages/svelte-form/src/createForm.public.ts | 14 +-- packages/svelte-form/src/createForm.svelte.ts | 12 +-- .../svelte-form/src/formApiTypes.public.ts | 26 +++-- packages/svelte-form/src/formType.public.ts | 14 ++- .../tests/createFormHook.test-d.ts | 2 +- 14 files changed, 123 insertions(+), 132 deletions(-) diff --git a/packages/lit-form/src/tanstack-form-controller.ts b/packages/lit-form/src/tanstack-form-controller.ts index eb8bd737e6..d536348d52 100644 --- a/packages/lit-form/src/tanstack-form-controller.ts +++ b/packages/lit-form/src/tanstack-form-controller.ts @@ -218,7 +218,7 @@ export class TanStackFormController< #unmount?: () => void get api(): FormApi { - return this.#form + return this.#form as never } /** diff --git a/packages/svelte-form/src/AppForm/SvelteAppFormApi.public.ts b/packages/svelte-form/src/AppForm/SvelteAppFormApi.public.ts index 2e403f494d..9243051602 100644 --- a/packages/svelte-form/src/AppForm/SvelteAppFormApi.public.ts +++ b/packages/svelte-form/src/AppForm/SvelteAppFormApi.public.ts @@ -1,14 +1,14 @@ -import type { FormErrorTypes } from '@tanstack/form-core' import type { Component, Snippet } from 'svelte' import type { SvelteFormApi } from '../formApiTypes.public' import type { AnySvelteFormComponentMap } from './componentMap.public' +import type { ScopedErrorTypes } from '@tanstack/form-core' export type AppFormComponent = Component<{ children: Snippet }> export type SvelteAppFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TComponents extends AnySvelteFormComponentMap, -> = SvelteFormApi & { +> = SvelteFormApi & { AppForm: AppFormComponent } diff --git a/packages/svelte-form/src/AppForm/createFormHook.public.ts b/packages/svelte-form/src/AppForm/createFormHook.public.ts index 6f9ff759ec..2cc4854b3c 100644 --- a/packages/svelte-form/src/AppForm/createFormHook.public.ts +++ b/packages/svelte-form/src/AppForm/createFormHook.public.ts @@ -9,7 +9,7 @@ import type { UseAppFormHook, } from './createFormHookTypes.public' import type { Component } from 'svelte' -import type { FormOptions } from '@tanstack/form-core' +import type { AnyFormOptions, FormOptions } from '@tanstack/form-core' export function createFormHook< const TFormComponents extends Record>, @@ -22,7 +22,7 @@ export function createFormHook< }> { const initializeAppForm = createAppFormInitializer(createOptions) - function useExtendedForm(options: () => FormOptions) { + function useExtendedForm(options: () => AnyFormOptions) { return createInternalForm(options, initializeAppForm) } const useAppForm = useExtendedForm as never as UseAppFormHook<{ diff --git a/packages/svelte-form/src/AppForm/createFormHookTypes.public.ts b/packages/svelte-form/src/AppForm/createFormHookTypes.public.ts index 33f74289d1..768084cbe7 100644 --- a/packages/svelte-form/src/AppForm/createFormHookTypes.public.ts +++ b/packages/svelte-form/src/AppForm/createFormHookTypes.public.ts @@ -11,8 +11,7 @@ import type { DefaultFormOptions, FormOptions, FormOptionsApi, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' /** @@ -97,18 +96,20 @@ export interface CreateFormHookOptions< export type UseAppFormHook = < TFormData, - TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( options: () => FormOptions< TFormData, - TFormValidators, + TValidatorConfig, + TValidators, TSubmitReturn, unknown >, ) => SvelteAppFormApi< TFormData, - ToFormErrorTypes, + GetFormErrorTypes, TComponents > diff --git a/packages/svelte-form/src/AppForm/initializeAppForm.lib.ts b/packages/svelte-form/src/AppForm/initializeAppForm.lib.ts index 0aab16c113..7eda53fe41 100644 --- a/packages/svelte-form/src/AppForm/initializeAppForm.lib.ts +++ b/packages/svelte-form/src/AppForm/initializeAppForm.lib.ts @@ -1,6 +1,7 @@ import { InternalFormApi } from '@tanstack/form-core/internals' import { attachSvelteAppFormComponents } from './Components.lib' import type { + AnyFormOptions, DefaultFieldOptions, DefaultFormGroupOptions, DefaultFormOptions, @@ -20,7 +21,7 @@ interface AnyCreateFormHookOptions { export function createAppFormInitializer( createOptions: AnyCreateFormHookOptions, -): (options: FormOptions) => InternalSvelteFormApi { +): (options: AnyFormOptions) => InternalSvelteFormApi { const hasDefaultOptions = createOptions.defaultFormOptions || createOptions.defaultFieldOptions || diff --git a/packages/svelte-form/src/Components.public.ts b/packages/svelte-form/src/Components.public.ts index 25d3405fd6..d91b30bf57 100644 --- a/packages/svelte-form/src/Components.public.ts +++ b/packages/svelte-form/src/Components.public.ts @@ -5,12 +5,12 @@ import type { FieldApi, FieldApiOptions, FieldValidators, - FormErrorTypes, FormGroupApi, FormGroupOptions, FormGroupState, FormGroupValidators, FormState, + ScopedErrorTypes, ToFieldError, ToFormGroupErrorTypes, } from '@tanstack/form-core' @@ -69,9 +69,9 @@ export type SvelteFieldApi< TFieldValue, TFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, -> = FieldApi & +> = FieldApi & FieldComponentsMatchingType interface SvelteSubscribeProps { @@ -82,16 +82,16 @@ interface SvelteSubscribeProps { export type SvelteFormSubscribeProps< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TSelected, -> = SvelteSubscribeProps, TSelected> +> = SvelteSubscribeProps, TSelected> export type SvelteFormSubscribeComponent< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > = (new ( options: ComponentConstructorOptions< - SvelteFormSubscribeProps + SvelteFormSubscribeProps >, ) => SvelteComponent) & Component & @@ -104,7 +104,7 @@ export interface SvelteFormFieldProps< TFieldValidators extends FieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > extends FieldApiOptions< TFieldData, @@ -113,20 +113,16 @@ export interface SvelteFormFieldProps< TFieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes + TFormError > { children: Snippet< [ SvelteFieldApi< TFieldName, TFieldValue, - ToFieldError< - NoInfer, - TGroupFieldError, - TFormErrorTypes - >, + ToFieldError, TGroupFieldError, TFormError>, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ] @@ -135,7 +131,7 @@ export interface SvelteFormFieldProps< export type SvelteFormFieldComponent< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > = (new < TFieldName extends DeepKeys, @@ -153,7 +149,7 @@ export type SvelteFormFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > >, @@ -163,7 +159,7 @@ export type SvelteFormFieldComponent< export type SvelteFormArrayFieldComponent< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > = (new < TFieldName extends DeepKeysWhereValueIncludes>, @@ -181,7 +177,7 @@ export type SvelteFormArrayFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > >, @@ -191,13 +187,10 @@ export type SvelteFormArrayFieldComponent< export type SvelteFormGroupSubscribeComponent< TGroupValue, - TGroupErrorTypes extends FormErrorTypes, + TGroupError extends ScopedErrorTypes, > = (new ( options: ComponentConstructorOptions< - SvelteSubscribeProps< - FormGroupState, - TSelected - > + SvelteSubscribeProps, TSelected> >, ) => SvelteComponent) & Component & @@ -206,8 +199,8 @@ export type SvelteFormGroupSubscribeComponent< export type SvelteFormGroupFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes extends FormErrorTypes, - TFormErrorTypes extends FormErrorTypes, + TGroupError extends ScopedErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > = (new < TFieldName extends DeepKeys, @@ -223,9 +216,9 @@ export type SvelteFormGroupFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['selfError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > >, @@ -236,8 +229,8 @@ export type SvelteFormGroupFieldComponent< export type SvelteFormGroupArrayFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes extends FormErrorTypes, - TFormErrorTypes extends FormErrorTypes, + TGroupError extends ScopedErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > = (new < TFieldName extends DeepKeysWhereValueIncludes>, @@ -253,9 +246,9 @@ export type SvelteFormGroupArrayFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['selfError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > >, @@ -267,31 +260,31 @@ export interface SvelteFormGroupApi< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes extends FormErrorTypes, - TFormErrorTypes extends FormErrorTypes, + TGroupError extends ScopedErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > extends FormGroupApi< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes + TGroupError, + TFormError > { Field: SvelteFormGroupFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes, + TGroupError, + TFormError, TFieldComponents > ArrayField: SvelteFormGroupArrayFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes, + TGroupError, + TFormError, TFieldComponents > - Subscribe: SvelteFormGroupSubscribeComponent + Subscribe: SvelteFormGroupSubscribeComponent } export interface SvelteFormGroupProps< @@ -299,7 +292,7 @@ export interface SvelteFormGroupProps< TGroupName, TGroupValue, TGroupValidators extends FormGroupValidators, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > extends Omit< FormGroupOptions< @@ -307,7 +300,7 @@ export interface SvelteFormGroupProps< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, 'form' > { @@ -318,7 +311,7 @@ export interface SvelteFormGroupProps< TGroupName, TGroupValue, ToFormGroupErrorTypes>, - TFormErrorTypes, + TFormError, TFieldComponents >, ] @@ -327,7 +320,7 @@ export interface SvelteFormGroupProps< export type SvelteFormGroupComponent< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > = (new < TGroupName extends DeepKeys, @@ -340,7 +333,7 @@ export type SvelteFormGroupComponent< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes, + TFormError, TFieldComponents > >, @@ -350,22 +343,18 @@ export type SvelteFormGroupComponent< export interface SvelteTanStackFormComponents< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record> = Record< never, never >, > { - Field: SvelteFormFieldComponent + Field: SvelteFormFieldComponent ArrayField: SvelteFormArrayFieldComponent< TFormData, - TFormErrorTypes, - TFieldComponents - > - Subscribe: SvelteFormSubscribeComponent - FormGroup: SvelteFormGroupComponent< - TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > + Subscribe: SvelteFormSubscribeComponent + FormGroup: SvelteFormGroupComponent } diff --git a/packages/svelte-form/src/Field.svelte b/packages/svelte-form/src/Field.svelte index 1142a5ad0d..40ed687fb3 100644 --- a/packages/svelte-form/src/Field.svelte +++ b/packages/svelte-form/src/Field.svelte @@ -4,7 +4,7 @@ FieldApi, FieldApiOptions, FieldValidators, - FormErrorTypes, + ScopedErrorTypes, ToFieldError, } from '@tanstack/form-core' import type { @@ -13,8 +13,15 @@ InternalBaseFieldMeta, } from '@tanstack/form-core/internals' - interface InternalFieldOptions - extends FieldApiOptions { + interface InternalFieldOptions extends FieldApiOptions< + any, + any, + any, + any, + any, + any, + any + > { form: AnyInternalFormApi } @@ -53,26 +60,24 @@ >, TGroupFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, >( - options: () => - & FieldApiOptions< - TFieldData, - TFieldName, - TFieldValue, - TFieldValidators, - TGroupFieldError, - TFormData, - TFormErrorTypes - > - & { form: AnyInternalFormApi }, + options: () => FieldApiOptions< + TFieldData, + TFieldName, + TFieldValue, + TFieldValidators, + TGroupFieldError, + TFormData, + TFormError + > & { form: AnyInternalFormApi }, array = false, ): FieldApi< TFieldName, TFieldValue, - ToFieldError, + ToFieldError, TFormData, - TFormErrorTypes + TFormError > { const initialForm = options().form const resetVersion = useSelector(initialForm._atoms.resetVersion) @@ -148,7 +153,10 @@ () => fieldOptions, untrack(() => array), ) - Object.assign(fieldApi, untrack(() => fieldComponents)) + Object.assign( + fieldApi, + untrack(() => fieldComponents), + ) setContext(fieldContextKey, fieldApi) diff --git a/packages/svelte-form/src/FieldGroup/FieldGroupApi.public.ts b/packages/svelte-form/src/FieldGroup/FieldGroupApi.public.ts index 9aea90676c..65da7cee43 100644 --- a/packages/svelte-form/src/FieldGroup/FieldGroupApi.public.ts +++ b/packages/svelte-form/src/FieldGroup/FieldGroupApi.public.ts @@ -5,8 +5,8 @@ import type { FieldValidators, FormApiArrayMethods, FormApiFieldMethods, - FormErrorTypes, FormState, + ScopedErrorTypes, ValidationIssue, } from '@tanstack/form-core' import type { @@ -37,7 +37,7 @@ export type FieldGroupFieldComponent< >, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents > >, @@ -63,7 +63,7 @@ export type FieldGroupArrayFieldComponent< >, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents > >, @@ -73,7 +73,7 @@ export type FieldGroupArrayFieldComponent< export type FieldGroupSubscribeComponent = (new ( options: ComponentConstructorOptions< - SvelteFormSubscribeProps + SvelteFormSubscribeProps >, ) => SvelteComponent) & Component & @@ -97,4 +97,4 @@ export type AnyFieldGroupApi = FieldGroupApi< any, Record> > -export type FieldGroupFormState = FormState +export type FieldGroupFormState = FormState diff --git a/packages/svelte-form/src/FormGroup.svelte b/packages/svelte-form/src/FormGroup.svelte index a903665e79..9e208fb7d0 100644 --- a/packages/svelte-form/src/FormGroup.svelte +++ b/packages/svelte-form/src/FormGroup.svelte @@ -3,10 +3,10 @@ import { useSelector } from '@tanstack/svelte-store' import { withComponentProps } from './utils.lib.js' import type { - FormErrorTypes, FormGroupApi, FormGroupOptions, FormGroupValidators, + ScopedErrorTypes, ToFormGroupErrorTypes, } from '@tanstack/form-core' import type { AnyInternalFormApi } from '@tanstack/form-core/internals' @@ -16,21 +16,21 @@ TGroupName, TGroupValue, const TGroupValidators extends FormGroupValidators, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, >( options: () => FormGroupOptions< TFormData, TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, ): FormGroupApi< TFormData, TGroupName, TGroupValue, ToFormGroupErrorTypes, - TFormErrorTypes + TFormError > { const group = new InternalFormGroupApi(options() as never) diff --git a/packages/svelte-form/src/createForm.public.ts b/packages/svelte-form/src/createForm.public.ts index 84a587d6fa..14d80c62b6 100644 --- a/packages/svelte-form/src/createForm.public.ts +++ b/packages/svelte-form/src/createForm.public.ts @@ -1,30 +1,32 @@ import { createInternalForm, initializeForm } from './createForm.svelte' import type { + AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' import type { SvelteFormApi } from './formApiTypes.public' import type { DefaultSvelteFormComponentMap } from './AppForm/componentMap.public' export type CreateForm = < TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( options: () => FormOptions< TFormData, - TFormValidators, + TValidatorConfig, + TValidators, TSubmitReturn, unknown >, ) => SvelteFormApi< TFormData, - ToFormErrorTypes, + GetFormErrorTypes, DefaultSvelteFormComponentMap > -function createFormImpl(options: () => FormOptions) { +function createFormImpl(options: () => AnyFormOptions) { return createInternalForm(options, initializeForm) } diff --git a/packages/svelte-form/src/createForm.svelte.ts b/packages/svelte-form/src/createForm.svelte.ts index 841078c427..5d2d33188d 100644 --- a/packages/svelte-form/src/createForm.svelte.ts +++ b/packages/svelte-form/src/createForm.svelte.ts @@ -5,7 +5,7 @@ import Field from './Field.svelte' import FormGroup from './FormGroup.svelte' import Subscribe from './Subscribe.svelte' import { withComponentProps } from './utils.lib.js' -import type { FormOptions } from '@tanstack/form-core' +import type { AnyFormOptions } from '@tanstack/form-core' import type { Component } from 'svelte' import type { AnyInternalFormApi } from '@tanstack/form-core/internals' import type { SvelteTanStackFormComponents } from './Components.public' @@ -55,17 +55,13 @@ export function attachSvelteFormComponents( return result } -export function initializeForm( - options: FormOptions, -): InternalSvelteFormApi { +export function initializeForm(options: AnyFormOptions): InternalSvelteFormApi { return attachSvelteFormComponents(new InternalFormApi(options), null) } export function createInternalForm( - options: () => FormOptions, - initialize: ( - options: FormOptions, - ) => InternalSvelteFormApi, + options: () => AnyFormOptions, + initialize: (options: AnyFormOptions) => InternalSvelteFormApi, ): InternalSvelteFormApi { const form = initialize(options()) diff --git a/packages/svelte-form/src/formApiTypes.public.ts b/packages/svelte-form/src/formApiTypes.public.ts index 2ec4e9ea43..d36cfc14b1 100644 --- a/packages/svelte-form/src/formApiTypes.public.ts +++ b/packages/svelte-form/src/formApiTypes.public.ts @@ -1,8 +1,8 @@ import type { AnyFormApi, FormApi, - FormErrorTypes, FormState, + ScopedErrorTypes, } from '@tanstack/form-core' import type { Component } from 'svelte' import type { SvelteTanStackFormComponents } from './Components.public' @@ -13,32 +13,28 @@ import type { export interface SvelteFormSelectors< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > { - useSelector: >( - selector?: (state: FormState) => TSelected, + useSelector: >( + selector?: (state: FormState) => TSelected, ) => { readonly current: TSelected } } type ExtendedFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, -> = FormApi & - SvelteTanStackFormComponents & - SvelteFormSelectors +> = FormApi & + SvelteTanStackFormComponents & + SvelteFormSelectors export type SvelteFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TComponents extends AnySvelteFormComponentMap = DefaultSvelteFormComponentMap, > = unknown extends TComponents['formComponents'] - ? ExtendedFormApi - : ExtendedFormApi< - TFormData, - TFormErrorTypes, - TComponents['fieldComponents'] - > & + ? ExtendedFormApi + : ExtendedFormApi & TComponents['formComponents'] /** diff --git a/packages/svelte-form/src/formType.public.ts b/packages/svelte-form/src/formType.public.ts index 44333b65ef..064c1965c0 100644 --- a/packages/svelte-form/src/formType.public.ts +++ b/packages/svelte-form/src/formType.public.ts @@ -1,8 +1,7 @@ import type { AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' import type { AnySvelteFormComponentMap, @@ -10,16 +9,15 @@ import type { } from './AppForm/componentMap.public' import type { SvelteFormApi } from './formApiTypes.public' -type SvelteFormTypeErrorTypes< - TValidators extends FormValidators, - TSubmitReturn, -> = unknown extends TSubmitReturn - ? any - : ToFormErrorTypes +type SvelteFormTypeErrorTypes = + unknown extends TSubmitReturn + ? any + : GetFormErrorTypes export type SvelteFormType = TOptions extends FormOptions< infer TData, + any, infer TValidators, infer TSubmitReturn, infer TComponents diff --git a/packages/svelte-form/tests/createFormHook.test-d.ts b/packages/svelte-form/tests/createFormHook.test-d.ts index 1c5383320e..daaa68f281 100644 --- a/packages/svelte-form/tests/createFormHook.test-d.ts +++ b/packages/svelte-form/tests/createFormHook.test-d.ts @@ -11,7 +11,7 @@ it('keeps hook defaults generic and infers values from each form', () => { { triggers: [], run: ({ value }) => { - expectTypeOf(value).toBeUnknown() + expectTypeOf(value).toBeAny() }, }, ], From f516ed91a408674e248c0ec0beda0e56004f10c1 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:55:38 +0100 Subject: [PATCH 32/43] fix: resolve solid-form types --- .../src/AppForm/SolidAppFormApi.public.ts | 6 +- .../src/AppForm/createFormHook.public.ts | 6 +- .../src/AppForm/createFormHookTypes.public.ts | 16 ++-- .../src/AppForm/initializeAppForm.lib.ts | 3 +- packages/solid-form/src/Components.public.ts | 90 +++++++++---------- .../src/FieldGroup/FieldGroupApi.public.ts | 10 +-- packages/solid-form/src/SolidFormApi.lib.ts | 12 +-- packages/solid-form/src/createForm.public.ts | 21 +++-- .../solid-form/src/formApiTypes.public.ts | 18 ++-- packages/solid-form/src/formType.public.ts | 14 ++- .../tests/createFormHook.test-d.tsx | 2 +- 11 files changed, 97 insertions(+), 101 deletions(-) diff --git a/packages/solid-form/src/AppForm/SolidAppFormApi.public.ts b/packages/solid-form/src/AppForm/SolidAppFormApi.public.ts index 3beade7b73..4554afeb6e 100644 --- a/packages/solid-form/src/AppForm/SolidAppFormApi.public.ts +++ b/packages/solid-form/src/AppForm/SolidAppFormApi.public.ts @@ -1,14 +1,14 @@ -import type { FormErrorTypes } from '@tanstack/form-core' import type { ParentComponent } from 'solid-js' import type { SolidFormApi } from '../formApiTypes.public' import type { AnySolidFormComponentMap } from './componentMap.public' +import type { ScopedErrorTypes } from '@tanstack/form-core' export type AppFormComponent = ParentComponent export type SolidAppFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TComponents extends AnySolidFormComponentMap, -> = SolidFormApi & { +> = SolidFormApi & { AppForm: AppFormComponent } diff --git a/packages/solid-form/src/AppForm/createFormHook.public.ts b/packages/solid-form/src/AppForm/createFormHook.public.ts index a66d1cf823..226893e7ed 100644 --- a/packages/solid-form/src/AppForm/createFormHook.public.ts +++ b/packages/solid-form/src/AppForm/createFormHook.public.ts @@ -9,7 +9,7 @@ import type { UseAppFormHook, } from './createFormHookTypes.public' import type { Accessor, Component } from 'solid-js' -import type { FormOptions } from '@tanstack/form-core' +import type { AnyFormOptions } from '@tanstack/form-core' export function createFormHook< const TFormComponents extends Record>, @@ -22,9 +22,7 @@ export function createFormHook< }> { const initializeAppForm = createAppFormInitializer(createOptions) - function useExtendedForm( - options: Accessor>, - ) { + function useExtendedForm(options: Accessor) { return createInternalForm(options, initializeAppForm) } const useAppForm = useExtendedForm as never as UseAppFormHook<{ diff --git a/packages/solid-form/src/AppForm/createFormHookTypes.public.ts b/packages/solid-form/src/AppForm/createFormHookTypes.public.ts index 4440f6da2d..aa7ab42c2a 100644 --- a/packages/solid-form/src/AppForm/createFormHookTypes.public.ts +++ b/packages/solid-form/src/AppForm/createFormHookTypes.public.ts @@ -11,8 +11,7 @@ import type { DefaultFormOptions, FormOptions, FormOptionsApi, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' /** @@ -97,15 +96,22 @@ export interface CreateFormHookOptions< export type UseAppFormHook = < TFormData, - TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( options: Accessor< - FormOptions + FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + > >, ) => SolidAppFormApi< TFormData, - ToFormErrorTypes, + GetFormErrorTypes, TComponents > diff --git a/packages/solid-form/src/AppForm/initializeAppForm.lib.ts b/packages/solid-form/src/AppForm/initializeAppForm.lib.ts index ddd8f49d09..05171a4d4e 100644 --- a/packages/solid-form/src/AppForm/initializeAppForm.lib.ts +++ b/packages/solid-form/src/AppForm/initializeAppForm.lib.ts @@ -1,6 +1,7 @@ import { InternalFormApi } from '@tanstack/form-core/internals' import { attachSolidAppFormComponents } from './Components.lib' import type { + AnyFormOptions, DefaultFieldOptions, DefaultFormGroupOptions, DefaultFormOptions, @@ -20,7 +21,7 @@ interface AnyCreateFormHookOptions { export function createAppFormInitializer( createOptions: AnyCreateFormHookOptions, -): (options: FormOptions) => InternalSolidFormApi { +): (options: AnyFormOptions) => InternalSolidFormApi { const hasDefaultOptions = createOptions.defaultFormOptions || createOptions.defaultFieldOptions || diff --git a/packages/solid-form/src/Components.public.ts b/packages/solid-form/src/Components.public.ts index a191ad823b..27c2b9ed52 100644 --- a/packages/solid-form/src/Components.public.ts +++ b/packages/solid-form/src/Components.public.ts @@ -5,12 +5,12 @@ import type { FieldApi, FieldApiOptions, FieldValidators, - FormErrorTypes, FormGroupApi, FormGroupOptions, FormGroupState, FormGroupValidators, FormState, + ScopedErrorTypes, ToFieldError, ToFormGroupErrorTypes, } from '@tanstack/form-core' @@ -64,10 +64,10 @@ export type SolidFieldApi< TFieldValue, TFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > = Accessor< - FieldApi + FieldApi > & FieldComponentsMatchingType @@ -79,15 +79,15 @@ interface SolidSubscribeProps { export type SolidFormSubscribeProps< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TSelected, -> = SolidSubscribeProps, TSelected> +> = SolidSubscribeProps, TSelected> export type SolidFormSubscribeComponent< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > = ( - props: SolidFormSubscribeProps, + props: SolidFormSubscribeProps, ) => JSX.Element export interface SolidFormFieldProps< @@ -97,7 +97,7 @@ export interface SolidFormFieldProps< TFieldValidators extends FieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > extends FieldApiOptions< TFieldData, @@ -106,19 +106,15 @@ export interface SolidFormFieldProps< TFieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes + TFormError > { children: ( fieldApi: SolidFieldApi< TFieldName, TFieldValue, - ToFieldError< - NoInfer, - TGroupFieldError, - TFormErrorTypes - >, + ToFieldError, TGroupFieldError, TFormError>, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ) => JSX.Element @@ -129,7 +125,7 @@ export interface SolidFormArrayFieldProps< TFieldName, TFieldValue, TFieldValidators extends FieldValidators, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, > extends SolidFormFieldProps< TFormData, TFieldName, @@ -137,13 +133,13 @@ export interface SolidFormArrayFieldProps< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, Record > {} export interface SolidFormFieldComponent< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > { < @@ -161,7 +157,7 @@ export interface SolidFormFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): JSX.Element @@ -169,7 +165,7 @@ export interface SolidFormFieldComponent< export interface SolidFormArrayFieldComponent< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > { < @@ -187,7 +183,7 @@ export interface SolidFormArrayFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): JSX.Element @@ -195,7 +191,7 @@ export interface SolidFormArrayFieldComponent< export type SolidFormGroupSubscribeProps< TGroupValue, - TGroupErrorTypes extends FormErrorTypes, + TGroupErrorTypes extends ScopedErrorTypes, TSelected, > = SolidSubscribeProps< FormGroupState, @@ -204,7 +200,7 @@ export type SolidFormGroupSubscribeProps< export type SolidFormGroupSubscribeComponent< TGroupValue, - TGroupErrorTypes extends FormErrorTypes, + TGroupErrorTypes extends ScopedErrorTypes, > = ( props: SolidFormGroupSubscribeProps, ) => JSX.Element @@ -212,8 +208,8 @@ export type SolidFormGroupSubscribeComponent< export interface SolidFormGroupFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes extends FormErrorTypes, - TFormErrorTypes extends FormErrorTypes, + TGroupError extends ScopedErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > { < @@ -229,9 +225,9 @@ export interface SolidFormGroupFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['selfError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): JSX.Element @@ -240,8 +236,8 @@ export interface SolidFormGroupFieldComponent< export interface SolidFormGroupArrayFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes extends FormErrorTypes, - TFormErrorTypes extends FormErrorTypes, + TGroupError extends ScopedErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > { < @@ -257,9 +253,9 @@ export interface SolidFormGroupArrayFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['selfError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): JSX.Element @@ -269,39 +265,39 @@ export interface SolidFormGroupApi< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes extends FormErrorTypes, - TFormErrorTypes extends FormErrorTypes, + TGroupError extends ScopedErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > extends FormGroupApi< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes + TGroupError, + TFormError > { Field: SolidFormGroupFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes, + TGroupError, + TFormError, TFieldComponents > ArrayField: SolidFormGroupArrayFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes, + TGroupError, + TFormError, TFieldComponents > - Subscribe: SolidFormGroupSubscribeComponent + Subscribe: SolidFormGroupSubscribeComponent } export type SolidFormGroupAccessor< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes extends FormErrorTypes, - TFormErrorTypes extends FormErrorTypes, + TGroupErrorTypes extends ScopedErrorTypes, + TFormErrorTypes extends ScopedErrorTypes, TFieldComponents extends Record>, > = Accessor< SolidFormGroupApi< @@ -330,7 +326,7 @@ export interface SolidFormGroupProps< TGroupName, TGroupValue, TGroupValidators extends FormGroupValidators, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, > extends Omit< FormGroupOptions< @@ -338,7 +334,7 @@ export interface SolidFormGroupProps< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, 'form' > { @@ -348,7 +344,7 @@ export interface SolidFormGroupProps< TGroupName, TGroupValue, ToFormGroupErrorTypes>, - TFormErrorTypes, + TFormError, TFieldComponents >, ) => JSX.Element @@ -356,7 +352,7 @@ export interface SolidFormGroupProps< export interface SolidFormGroupComponent< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormErrorTypes extends ScopedErrorTypes, TFieldComponents extends Record>, > { < @@ -377,7 +373,7 @@ export interface SolidFormGroupComponent< export interface SolidTanStackFormComponents< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormErrorTypes extends ScopedErrorTypes, TFieldComponents extends Record> = Record< never, never diff --git a/packages/solid-form/src/FieldGroup/FieldGroupApi.public.ts b/packages/solid-form/src/FieldGroup/FieldGroupApi.public.ts index 56fb75c5c0..363277ec4f 100644 --- a/packages/solid-form/src/FieldGroup/FieldGroupApi.public.ts +++ b/packages/solid-form/src/FieldGroup/FieldGroupApi.public.ts @@ -5,8 +5,8 @@ import type { FieldValidators, FormApiArrayMethods, FormApiFieldMethods, - FormErrorTypes, FormState, + ScopedErrorTypes, ValidationIssue, } from '@tanstack/form-core' import type { Component, JSX } from 'solid-js' @@ -32,7 +32,7 @@ export interface FieldGroupFieldComponent< >, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents >, ): JSX.Element @@ -54,7 +54,7 @@ export interface FieldGroupArrayFieldComponent< >, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents >, ): JSX.Element @@ -62,7 +62,7 @@ export interface FieldGroupArrayFieldComponent< export type FieldGroupSubscribeProps = SolidFormSubscribeProps< unknown, - FormErrorTypes, + ScopedErrorTypes, TSelected > @@ -88,4 +88,4 @@ export type AnyFieldGroupApi = FieldGroupApi< any, Record> > -export type FieldGroupFormState = FormState +export type FieldGroupFormState = FormState diff --git a/packages/solid-form/src/SolidFormApi.lib.ts b/packages/solid-form/src/SolidFormApi.lib.ts index 4c0ae80726..2e32d75edf 100644 --- a/packages/solid-form/src/SolidFormApi.lib.ts +++ b/packages/solid-form/src/SolidFormApi.lib.ts @@ -18,7 +18,7 @@ import type { AnyInternalFormApi, InternalFormGroupApi as InternalFormGroupApiType, } from '@tanstack/form-core/internals' -import type { FormOptions } from '@tanstack/form-core' +import type { AnyFormOptions } from '@tanstack/form-core' import type { Component } from 'solid-js' import type { SolidFormFieldProps, @@ -143,17 +143,13 @@ function attachSolidFormGroupComponents( return resultGroup } -export function initializeForm( - options: FormOptions, -): InternalSolidFormApi { +export function initializeForm(options: AnyFormOptions): InternalSolidFormApi { return attachSolidFormComponents(new InternalFormApi(options), null) } export function createInternalForm( - options: () => FormOptions, - initializeFn: ( - options: FormOptions, - ) => InternalSolidFormApi, + options: () => AnyFormOptions, + initializeFn: (options: AnyFormOptions) => InternalSolidFormApi, ) { const solidFormId = createUniqueId() const resolveOptions = () => { diff --git a/packages/solid-form/src/createForm.public.ts b/packages/solid-form/src/createForm.public.ts index 22e49a2a9d..8edec51af3 100644 --- a/packages/solid-form/src/createForm.public.ts +++ b/packages/solid-form/src/createForm.public.ts @@ -1,8 +1,8 @@ import { createInternalForm, initializeForm } from './SolidFormApi.lib' import type { + AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' import type { Accessor } from 'solid-js' import type { SolidFormApi } from './formApiTypes.public' @@ -10,21 +10,26 @@ import type { DefaultSolidFormComponentMap } from './AppForm/componentMap.public export type CreateFormHook = < TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( options: Accessor< - FormOptions + FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + > >, ) => SolidFormApi< TFormData, - ToFormErrorTypes, + GetFormErrorTypes, DefaultSolidFormComponentMap > -function createFormHook( - options: Accessor>, -) { +function createFormHook(options: Accessor) { return createInternalForm(options, initializeForm) } diff --git a/packages/solid-form/src/formApiTypes.public.ts b/packages/solid-form/src/formApiTypes.public.ts index 3d9066ab79..02b8b8c8cc 100644 --- a/packages/solid-form/src/formApiTypes.public.ts +++ b/packages/solid-form/src/formApiTypes.public.ts @@ -1,4 +1,4 @@ -import type { AnyFormApi, FormApi, FormErrorTypes } from '@tanstack/form-core' +import type { AnyFormApi, FormApi, ScopedErrorTypes } from '@tanstack/form-core' import type { Component } from 'solid-js' import type { SolidTanStackFormComponents } from './Components.public' import type { @@ -8,22 +8,18 @@ import type { type ExtendedFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record>, -> = FormApi & - SolidTanStackFormComponents +> = FormApi & + SolidTanStackFormComponents export type SolidFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TComponents extends AnySolidFormComponentMap = DefaultSolidFormComponentMap, > = unknown extends TComponents['formComponents'] - ? ExtendedFormApi - : ExtendedFormApi< - TFormData, - TFormErrorTypes, - TComponents['fieldComponents'] - > & + ? ExtendedFormApi + : ExtendedFormApi & TComponents['formComponents'] /** diff --git a/packages/solid-form/src/formType.public.ts b/packages/solid-form/src/formType.public.ts index aed344edc0..ed5978633c 100644 --- a/packages/solid-form/src/formType.public.ts +++ b/packages/solid-form/src/formType.public.ts @@ -1,8 +1,7 @@ import type { AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' import type { AnySolidFormComponentMap, @@ -10,12 +9,10 @@ import type { } from './AppForm/componentMap.public' import type { SolidFormApi } from './formApiTypes.public' -type SolidFormTypeErrorTypes< - TFormValidators extends FormValidators, - TSubmitReturn, -> = unknown extends TSubmitReturn - ? any - : ToFormErrorTypes +type SolidFormTypeErrorTypes = + unknown extends TSubmitReturn + ? any + : GetFormErrorTypes /** * Derives the Solid form API type represented by a reusable options object. @@ -49,6 +46,7 @@ type SolidFormTypeErrorTypes< export type SolidFormType = TOptions extends FormOptions< infer TFormData, + any, infer TFormValidators, infer TSubmitReturn, infer TComponents diff --git a/packages/solid-form/tests/createFormHook.test-d.tsx b/packages/solid-form/tests/createFormHook.test-d.tsx index 2edb3982b7..45d6eeba8a 100644 --- a/packages/solid-form/tests/createFormHook.test-d.tsx +++ b/packages/solid-form/tests/createFormHook.test-d.tsx @@ -10,7 +10,7 @@ const { useAppForm } = createFormHook({ { triggers: [], run: ({ value }) => { - expectTypeOf(value).toBeUnknown() + expectTypeOf(value).toBeAny() }, }, ], From 33796f31feee71ffee85f1e25aa794b03978d94e Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:08:07 +0100 Subject: [PATCH 33/43] fix: resolve vue-form types --- packages/solid-form/src/Components.public.ts | 4 +- packages/svelte-form/src/Components.public.ts | 4 +- .../src/AppForm/VueAppFormApi.public.ts | 6 +- .../src/AppForm/createFormHook.public.ts | 4 +- .../src/AppForm/createFormHookTypes.public.ts | 16 ++- .../src/AppForm/initializeAppForm.lib.ts | 3 +- .../src/FieldGroup/FieldGroupApi.public.ts | 22 +-- .../vue-form/src/VueForm/Components.public.ts | 127 +++++++++--------- .../vue-form/src/VueForm/VueFormApi.lib.ts | 12 +- .../src/VueForm/formApiTypes.public.ts | 18 +-- .../vue-form/src/VueForm/formType.public.ts | 14 +- .../vue-form/src/VueForm/useForm.public.ts | 19 ++- .../vue-form/tests/createFormHook.test-d.ts | 2 +- 13 files changed, 127 insertions(+), 124 deletions(-) diff --git a/packages/solid-form/src/Components.public.ts b/packages/solid-form/src/Components.public.ts index 27c2b9ed52..4fa8e35124 100644 --- a/packages/solid-form/src/Components.public.ts +++ b/packages/solid-form/src/Components.public.ts @@ -225,7 +225,7 @@ export interface SolidFormGroupFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupError['selfError'], + TGroupError['subfieldError'], TFormData, TFormError, TFieldComponents @@ -253,7 +253,7 @@ export interface SolidFormGroupArrayFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupError['selfError'], + TGroupError['subfieldError'], TFormData, TFormError, TFieldComponents diff --git a/packages/svelte-form/src/Components.public.ts b/packages/svelte-form/src/Components.public.ts index d91b30bf57..765e5e6c3a 100644 --- a/packages/svelte-form/src/Components.public.ts +++ b/packages/svelte-form/src/Components.public.ts @@ -216,7 +216,7 @@ export type SvelteFormGroupFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupError['selfError'], + TGroupError['subfieldError'], TFormData, TFormError, TFieldComponents @@ -246,7 +246,7 @@ export type SvelteFormGroupArrayFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupError['selfError'], + TGroupError['subfieldError'], TFormData, TFormError, TFieldComponents diff --git a/packages/vue-form/src/AppForm/VueAppFormApi.public.ts b/packages/vue-form/src/AppForm/VueAppFormApi.public.ts index 35828f5780..b89f42b6ec 100644 --- a/packages/vue-form/src/AppForm/VueAppFormApi.public.ts +++ b/packages/vue-form/src/AppForm/VueAppFormApi.public.ts @@ -1,4 +1,4 @@ -import type { FormErrorTypes } from '@tanstack/form-core' +import type { ScopedErrorTypes } from '@tanstack/form-core' import type { PublicProps } from 'vue' import type { VueComponentInstance } from '../vueTypes.lib' import type { VueFormApi } from '../VueForm/formApiTypes.public' @@ -10,8 +10,8 @@ export type AppFormComponent = new ( export type VueAppFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TComponents extends AnyVueFormComponentMap, -> = VueFormApi & { +> = VueFormApi & { AppForm: AppFormComponent } diff --git a/packages/vue-form/src/AppForm/createFormHook.public.ts b/packages/vue-form/src/AppForm/createFormHook.public.ts index adb99974f7..ef10720040 100644 --- a/packages/vue-form/src/AppForm/createFormHook.public.ts +++ b/packages/vue-form/src/AppForm/createFormHook.public.ts @@ -9,7 +9,7 @@ import type { UseAppFormHook, } from './createFormHookTypes.public' import type { Component } from 'vue' -import type { FormOptions } from '@tanstack/form-core' +import type { AnyFormOptions, FormOptions } from '@tanstack/form-core' export function createFormHook< const TFormComponents extends Record, @@ -22,7 +22,7 @@ export function createFormHook< }> { const initializeAppForm = createAppFormInitializer(createOptions) - function useExtendedForm(options: FormOptions) { + function useExtendedForm(options: AnyFormOptions) { return useInternalForm(options, initializeAppForm) } diff --git a/packages/vue-form/src/AppForm/createFormHookTypes.public.ts b/packages/vue-form/src/AppForm/createFormHookTypes.public.ts index 88805e4cd0..9db3d9872e 100644 --- a/packages/vue-form/src/AppForm/createFormHookTypes.public.ts +++ b/packages/vue-form/src/AppForm/createFormHookTypes.public.ts @@ -11,8 +11,7 @@ import type { DefaultFormOptions, FormOptions, FormOptionsApi, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' /** @@ -97,13 +96,20 @@ export interface CreateFormHookOptions< export type UseAppFormHook = < TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( - options: FormOptions, + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, ) => VueAppFormApi< TFormData, - ToFormErrorTypes, + GetFormErrorTypes, TComponents > diff --git a/packages/vue-form/src/AppForm/initializeAppForm.lib.ts b/packages/vue-form/src/AppForm/initializeAppForm.lib.ts index 25d02623cc..d3b9382307 100644 --- a/packages/vue-form/src/AppForm/initializeAppForm.lib.ts +++ b/packages/vue-form/src/AppForm/initializeAppForm.lib.ts @@ -1,6 +1,7 @@ import { InternalFormApi } from '@tanstack/form-core/internals' import { attachVueAppFormComponents } from './Components.lib' import type { + AnyFormOptions, DefaultFieldOptions, DefaultFormGroupOptions, DefaultFormOptions, @@ -20,7 +21,7 @@ interface AnyCreateFormHookOptions { export function createAppFormInitializer( createOptions: AnyCreateFormHookOptions, -): (options: FormOptions) => InternalVueFormApi { +): (options: AnyFormOptions) => InternalVueFormApi { const hasDefaultOptions = createOptions.defaultFormOptions || createOptions.defaultFieldOptions || diff --git a/packages/vue-form/src/FieldGroup/FieldGroupApi.public.ts b/packages/vue-form/src/FieldGroup/FieldGroupApi.public.ts index 11105668fc..265153ee75 100644 --- a/packages/vue-form/src/FieldGroup/FieldGroupApi.public.ts +++ b/packages/vue-form/src/FieldGroup/FieldGroupApi.public.ts @@ -5,8 +5,8 @@ import type { FieldValidators, FormApiArrayMethods, FormApiFieldMethods, - FormErrorTypes, FormState, + ScopedErrorTypes, ToFieldError, ValidationIssue, } from '@tanstack/form-core' @@ -30,7 +30,7 @@ export type FieldGroupFieldComponent< FieldValidators>, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents > & PublicProps, @@ -42,7 +42,7 @@ export type FieldGroupFieldComponent< FieldValidators>, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents >, { @@ -57,10 +57,10 @@ export type FieldGroupFieldComponent< DeepValue >, ValidationIssue, - FormErrorTypes + ScopedErrorTypes >, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents > } @@ -80,7 +80,7 @@ export type FieldGroupArrayFieldComponent< FieldValidators>, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents > & PublicProps, @@ -92,7 +92,7 @@ export type FieldGroupArrayFieldComponent< FieldValidators>, ValidationIssue, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents >, { @@ -107,10 +107,10 @@ export type FieldGroupArrayFieldComponent< DeepValue >, ValidationIssue, - FormErrorTypes + ScopedErrorTypes >, unknown, - FormErrorTypes, + ScopedErrorTypes, TFieldComponents > } @@ -119,7 +119,7 @@ export type FieldGroupArrayFieldComponent< export type FieldGroupSubscribeProps = VueFormSubscribeProps< unknown, - FormErrorTypes, + ScopedErrorTypes, TSelected > @@ -145,4 +145,4 @@ export interface FieldGroupApi< } export type AnyFieldGroupApi = FieldGroupApi> -export type FieldGroupFormState = FormState +export type FieldGroupFormState = FormState diff --git a/packages/vue-form/src/VueForm/Components.public.ts b/packages/vue-form/src/VueForm/Components.public.ts index bca6531971..e9a9dbb704 100644 --- a/packages/vue-form/src/VueForm/Components.public.ts +++ b/packages/vue-form/src/VueForm/Components.public.ts @@ -5,12 +5,12 @@ import type { FieldApi, FieldApiOptions, FieldValidators, - FormErrorTypes, FormGroupApi, FormGroupOptions, FormGroupState, FormGroupValidators, FormState, + ScopedErrorTypes, ToFieldError, ToFormGroupErrorTypes, } from '@tanstack/form-core' @@ -67,9 +67,9 @@ export type VueFieldApi< TFieldValue, TFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record, -> = FieldApi & +> = FieldApi & FieldComponentsMatchingType interface VueSubscribeProps { @@ -79,18 +79,17 @@ interface VueSubscribeProps { export type VueFormSubscribeProps< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TSelected, -> = VueSubscribeProps, TSelected> +> = VueSubscribeProps, TSelected> export type VueFormSubscribeComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, > = new ( - props: VueFormSubscribeProps & - PublicProps, + props: VueFormSubscribeProps & PublicProps, ) => VueComponentInstance< - VueFormSubscribeProps, + VueFormSubscribeProps, { default: NoInfer } > @@ -105,7 +104,7 @@ export interface VueFormFieldProps< >, in out TGroupFieldError, in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record, > extends FieldApiOptions< TFieldData, @@ -114,14 +113,14 @@ export interface VueFormFieldProps< TFieldValidators, TGroupFieldError, TFormData, - TFormErrorTypes + TFormError > { readonly [fieldComponentsType]?: TFieldComponents } export type VueFormFieldComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record, > = new < TFieldName extends DeepKeys, @@ -138,7 +137,7 @@ export type VueFormFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > & PublicProps, @@ -150,7 +149,7 @@ export type VueFormFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, { @@ -158,9 +157,9 @@ export type VueFormFieldComponent< field: VueFieldApi< TFieldName, DeepValue, - ToFieldError, never, TFormErrorTypes>, + ToFieldError, never, TFormError>, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > } @@ -169,7 +168,7 @@ export type VueFormFieldComponent< export type VueFormArrayFieldComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record, > = new < TFieldName extends DeepKeysWhereValueIncludes>, @@ -186,7 +185,7 @@ export type VueFormArrayFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > & PublicProps, @@ -198,7 +197,7 @@ export type VueFormArrayFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, { @@ -206,9 +205,9 @@ export type VueFormArrayFieldComponent< field: VueFieldApi< TFieldName, DeepValue, - ToFieldError, never, TFormErrorTypes>, + ToFieldError, never, TFormError>, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > } @@ -217,26 +216,26 @@ export type VueFormArrayFieldComponent< export type VueFormGroupSubscribeProps< TGroupValue, - TGroupErrorTypes extends FormErrorTypes, + TGroupError extends ScopedErrorTypes, TSelected, -> = VueSubscribeProps, TSelected> +> = VueSubscribeProps, TSelected> export type VueFormGroupSubscribeComponent< in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, > = new ( - props: VueFormGroupSubscribeProps & + props: VueFormGroupSubscribeProps & PublicProps, ) => VueComponentInstance< - VueFormGroupSubscribeProps, + VueFormGroupSubscribeProps, { default: NoInfer } > export type VueFormGroupFieldComponent< in out TFormData, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record, > = new < TFieldName extends DeepKeys, @@ -251,9 +250,9 @@ export type VueFormGroupFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['subfieldError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > & PublicProps, @@ -263,9 +262,9 @@ export type VueFormGroupFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['subfieldError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, { @@ -275,11 +274,11 @@ export type VueFormGroupFieldComponent< DeepValue, ToFieldError< NoInfer, - TGroupErrorTypes['fieldError'], - TFormErrorTypes + TGroupError['subfieldError'], + TFormError >, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > } @@ -289,8 +288,8 @@ export type VueFormGroupFieldComponent< export type VueFormGroupArrayFieldComponent< in out TFormData, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record, > = new < TFieldName extends DeepKeysWhereValueIncludes>, @@ -305,9 +304,9 @@ export type VueFormGroupArrayFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['subfieldError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > & PublicProps, @@ -317,9 +316,9 @@ export type VueFormGroupArrayFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['subfieldError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, { @@ -329,11 +328,11 @@ export type VueFormGroupArrayFieldComponent< DeepValue, ToFieldError< NoInfer, - TGroupErrorTypes['fieldError'], - TFormErrorTypes + TGroupError['subfieldError'], + TFormError >, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > } @@ -344,31 +343,31 @@ export interface VueFormGroupApi< in out TFormData, in out TGroupName, in out TGroupValue, - in out TGroupErrorTypes extends FormErrorTypes, - in out TFormErrorTypes extends FormErrorTypes, + in out TGroupError extends ScopedErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record, > extends FormGroupApi< TFormData, TGroupName, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes + TGroupError, + TFormError > { Field: VueFormGroupFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes, + TGroupError, + TFormError, TFieldComponents > ArrayField: VueFormGroupArrayFieldComponent< TFormData, TGroupValue, - TGroupErrorTypes, - TFormErrorTypes, + TGroupError, + TFormError, TFieldComponents > - Subscribe: VueFormGroupSubscribeComponent + Subscribe: VueFormGroupSubscribeComponent } export interface VueFormGroupProps< @@ -376,7 +375,7 @@ export interface VueFormGroupProps< in out TGroupName, in out TGroupValue, in out TGroupValidators extends FormGroupValidators, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record, > extends Omit< FormGroupOptions< @@ -384,7 +383,7 @@ export interface VueFormGroupProps< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, 'form' > { @@ -393,7 +392,7 @@ export interface VueFormGroupProps< export type VueFormGroupComponent< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record, > = new < TGroupName extends DeepKeys, @@ -405,7 +404,7 @@ export type VueFormGroupComponent< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes, + TFormError, TFieldComponents > & PublicProps, @@ -415,7 +414,7 @@ export type VueFormGroupComponent< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes, + TFormError, TFieldComponents >, { @@ -425,7 +424,7 @@ export type VueFormGroupComponent< TGroupName, TGroupValue, ToFormGroupErrorTypes>, - TFormErrorTypes, + TFormError, TFieldComponents > } @@ -434,18 +433,18 @@ export type VueFormGroupComponent< export interface VueTanStackFormComponents< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + in out TFormError extends ScopedErrorTypes, in out TFieldComponents extends Record = Record< never, never >, > { - Field: VueFormFieldComponent + Field: VueFormFieldComponent ArrayField: VueFormArrayFieldComponent< TFormData, - TFormErrorTypes, + TFormError, TFieldComponents > - Subscribe: VueFormSubscribeComponent - FormGroup: VueFormGroupComponent + Subscribe: VueFormSubscribeComponent + FormGroup: VueFormGroupComponent } diff --git a/packages/vue-form/src/VueForm/VueFormApi.lib.ts b/packages/vue-form/src/VueForm/VueFormApi.lib.ts index c766b3ea2f..39f33bf7b2 100644 --- a/packages/vue-form/src/VueForm/VueFormApi.lib.ts +++ b/packages/vue-form/src/VueForm/VueFormApi.lib.ts @@ -2,23 +2,19 @@ import { InternalFormApi } from '@tanstack/form-core/internals' import { onMounted, onUnmounted, useId, watchEffect } from 'vue' import { attachVueFormComponents } from './Components.lib' import type { AnyInternalFormApi } from '@tanstack/form-core/internals' -import type { FormOptions } from '@tanstack/form-core' +import type { AnyFormOptions } from '@tanstack/form-core' import type { VueTanStackFormComponents } from './Components.public' export interface InternalVueFormApi extends AnyInternalFormApi, VueTanStackFormComponents {} -export function initializeForm( - options: FormOptions, -): InternalVueFormApi { +export function initializeForm(options: AnyFormOptions): InternalVueFormApi { return attachVueFormComponents(new InternalFormApi(options), null) } export function useInternalForm( - options: FormOptions, - initializeFn: ( - options: FormOptions, - ) => InternalVueFormApi, + options: AnyFormOptions, + initializeFn: (options: AnyFormOptions) => InternalVueFormApi, ) { const vueFormId = useId() const resolveOptions = () => diff --git a/packages/vue-form/src/VueForm/formApiTypes.public.ts b/packages/vue-form/src/VueForm/formApiTypes.public.ts index 720305e9b6..795d57e231 100644 --- a/packages/vue-form/src/VueForm/formApiTypes.public.ts +++ b/packages/vue-form/src/VueForm/formApiTypes.public.ts @@ -1,4 +1,4 @@ -import type { AnyFormApi, FormApi, FormErrorTypes } from '@tanstack/form-core' +import type { AnyFormApi, FormApi, ScopedErrorTypes } from '@tanstack/form-core' import type { Component } from 'vue' import type { VueTanStackFormComponents } from './Components.public' import type { @@ -8,22 +8,18 @@ import type { type ExtendedFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends Record, -> = FormApi & - VueTanStackFormComponents +> = FormApi & + VueTanStackFormComponents export type VueFormApi< TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TComponents extends AnyVueFormComponentMap = DefaultVueFormComponentMap, > = unknown extends TComponents['formComponents'] - ? ExtendedFormApi - : ExtendedFormApi< - TFormData, - TFormErrorTypes, - TComponents['fieldComponents'] - > & + ? ExtendedFormApi + : ExtendedFormApi & TComponents['formComponents'] /** diff --git a/packages/vue-form/src/VueForm/formType.public.ts b/packages/vue-form/src/VueForm/formType.public.ts index 323d407884..2974d3a8fb 100644 --- a/packages/vue-form/src/VueForm/formType.public.ts +++ b/packages/vue-form/src/VueForm/formType.public.ts @@ -1,8 +1,7 @@ import type { AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' import type { AnyVueFormComponentMap, @@ -10,12 +9,10 @@ import type { } from '../AppForm/componentMap.public' import type { VueFormApi } from './formApiTypes.public' -type VueFormTypeErrorTypes< - TFormValidators extends FormValidators, - TSubmitReturn, -> = unknown extends TSubmitReturn - ? any - : ToFormErrorTypes +type VueFormTypeErrorTypes = + unknown extends TSubmitReturn + ? any + : GetFormErrorTypes /** * Derives the Vue form API type represented by a reusable options object. @@ -43,6 +40,7 @@ type VueFormTypeErrorTypes< export type VueFormType = TOptions extends FormOptions< infer TFormData, + any, infer TFormValidators, infer TSubmitReturn, infer TComponents diff --git a/packages/vue-form/src/VueForm/useForm.public.ts b/packages/vue-form/src/VueForm/useForm.public.ts index 9a6a3d7670..e6d33ed35a 100644 --- a/packages/vue-form/src/VueForm/useForm.public.ts +++ b/packages/vue-form/src/VueForm/useForm.public.ts @@ -1,8 +1,8 @@ import { initializeForm, useInternalForm } from './VueFormApi.lib' import type { + AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' import type { AnyVueFormComponentMap, @@ -12,17 +12,24 @@ import type { VueFormApi } from './formApiTypes.public' export type UseFormHook = < TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( - options: FormOptions, + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, ) => VueFormApi< TFormData, - ToFormErrorTypes, + GetFormErrorTypes, TComponents > -function useFormHook(options: FormOptions) { +function useFormHook(options: AnyFormOptions) { return useInternalForm(options, initializeForm) } diff --git a/packages/vue-form/tests/createFormHook.test-d.ts b/packages/vue-form/tests/createFormHook.test-d.ts index b8a3e62f2f..510aff143f 100644 --- a/packages/vue-form/tests/createFormHook.test-d.ts +++ b/packages/vue-form/tests/createFormHook.test-d.ts @@ -11,7 +11,7 @@ it('keeps value inference local to each form instance', () => { { triggers: [], run: ({ value }) => { - expectTypeOf(value).toBeUnknown() + expectTypeOf(value).toBeAny() }, }, ], From ecac098ccdc9d75cfb3445b39d4d49bb89222b0e Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:10:51 +0100 Subject: [PATCH 34/43] fix: resolve form-devtools types --- .../src/bridge/fields/debug/serverErrorOnUnmountedField.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts b/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts index 5a6c1a5791..dd9a1505e2 100644 --- a/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts +++ b/packages/form-devtools/src/bridge/fields/debug/serverErrorOnUnmountedField.ts @@ -12,7 +12,7 @@ export const serverErrorOnUnmountedField = { const validator = field.form._validatorInstances?.[error.source.validatorIndex]?.definition - const hasServerTrigger = validator?.triggers.includes('server') + const hasServerTrigger = validator?.triggers?.includes('server') if (!hasServerTrigger) return undefined From 711553dffdfbd53f87731a260e0d778adc2fde28 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:23:47 +0100 Subject: [PATCH 35/43] fix: resolve types in SSR packages --- packages/react-form-nextjs/src/index.ts | 46 +++++++--------- packages/react-form-start/src/index.ts | 28 ++++++---- packages/react-form-start/src/utils.ts | 24 +++++++-- packages/react-form-start/tests/start.spec.ts | 4 +- .../react-form-start/tests/start.test-d.ts | 52 +++++++------------ packages/react-form-start/vite.config.ts | 16 +----- 6 files changed, 79 insertions(+), 91 deletions(-) diff --git a/packages/react-form-nextjs/src/index.ts b/packages/react-form-nextjs/src/index.ts index 1ca5bf0958..217fb55e5f 100644 --- a/packages/react-form-nextjs/src/index.ts +++ b/packages/react-form-nextjs/src/index.ts @@ -1,17 +1,13 @@ import { decode } from 'decode-formdata' import { validateServerValues } from '@tanstack/form-core/internals' import type { + AnyFormOptions, FormOptions, - FormValidators, - ServerValidateFrameworkPlugin, ServerValidateResult, } from '@tanstack/form-core' import type { FormDataInfo } from 'decode-formdata' -export type NextServerValidate< - TFormData, - TFormValidators extends FormValidators, -> = ( +export type NextServerValidate = ( formData: FormData, ) => Promise> @@ -21,11 +17,18 @@ export interface NextServerValidateOptions { type NextCreateServerValidate = < TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TValidators, TSubmitReturn, >( - options: FormOptions, -) => NextServerValidate + options: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, +) => NextServerValidate function decodeFormData( formData: FormData, @@ -34,28 +37,15 @@ function decodeFormData( return (info ? decode(formData, info) : decode(formData)) as TFormData } -export function next( - options: NextServerValidateOptions = {}, -): ServerValidateFrameworkPlugin { +export function next(options: NextServerValidateOptions = {}) { return { - id: 'react-form-nextjs', - createServerValidate: < - TFormData, - const TFormValidators extends FormValidators, - TSubmitReturn, - >( - formOptions: FormOptions< - TFormData, - TFormValidators, - TSubmitReturn, - unknown - >, - ) => { - return async (formData) => { - const values = decodeFormData(formData, options.info) + id: 'react-form-nextjs' as const, + createServerValidate: ((formOptions: AnyFormOptions) => { + return async (formData: FormData) => { + const values = decodeFormData(formData, options.info) return validateServerValues(formOptions, values) } - }, + }) satisfies NextCreateServerValidate, } } diff --git a/packages/react-form-start/src/index.ts b/packages/react-form-start/src/index.ts index 7c448bf0e5..f52ff17cd9 100644 --- a/packages/react-form-start/src/index.ts +++ b/packages/react-form-start/src/index.ts @@ -5,7 +5,6 @@ import { setInternalTanStackCookie } from './utils' import type { DeepKeysWhereValueIncludes, FormOptions, - FormValidators, ServerFormState, ServerValidateFailure, ServerValidateResult, @@ -26,7 +25,7 @@ export interface StartServerValidateContext { export type StartServerValidateResult< TFormData, - TFormValidators extends FormValidators, + TFormValidators, TOnInvalidReturn = never, TOnValidReturn = never, > = @@ -40,8 +39,8 @@ export interface StartServerValidateOptions< > { info?: FormDataInfo onInvalid?: (args: { - result: ServerValidateFailure - serverState: ServerFormState + result: ServerValidateFailure + serverState: ServerFormState }) => TOnInvalidReturn | Promise onValid?: (args: { result: ServerValidateSuccess @@ -55,7 +54,7 @@ export interface StartCreateServerValidateOptions { export type StartServerValidateAction< TFormData, - TFormValidators extends FormValidators, + TFormValidators, TOnInvalidReturn = never, TOnValidReturn = never, > = { @@ -69,7 +68,7 @@ export type StartServerValidateAction< TOnValidReturn > > - getFormData: () => Promise> + getFormData: () => Promise> } export type StartCreateServerValidate< @@ -77,10 +76,17 @@ export type StartCreateServerValidate< TOnValidReturn = never, > = < TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TFormValidators, TSubmitReturn, >( - formOptions: FormOptions, + formOptions: FormOptions< + TFormData, + TValidatorConfig, + TFormValidators, + TSubmitReturn, + unknown + >, options?: StartCreateServerValidateOptions, ) => StartServerValidateAction< TFormData, @@ -158,11 +164,13 @@ export function start( ) { const createServerValidate = < TFormData, - const TFormValidators extends FormValidators, + const TValidatorConfig extends unknown[], + TFormValidators, TSubmitReturn, >( formOptions: FormOptions< TFormData, + TValidatorConfig, TFormValidators, TSubmitReturn, unknown @@ -217,7 +225,7 @@ export function start( return Object.assign(validate, { getFormData: getFormData as unknown as () => Promise< - ServerFormState + ServerFormState >, }) } diff --git a/packages/react-form-start/src/utils.ts b/packages/react-form-start/src/utils.ts index 1805b6d7e4..ff927dd99a 100644 --- a/packages/react-form-start/src/utils.ts +++ b/packages/react-form-start/src/utils.ts @@ -9,17 +9,31 @@ import type { ServerFormState } from '@tanstack/form-core' const INTERNALS_COOKIE_NAME = '_tanstack_form_internals' export function setInternalTanStackCookie( - serverState: ServerFormState, + serverState: ServerFormState, ): void { - setCookie(INTERNALS_COOKIE_NAME, stringify(serverState)) + const state = { + ...serverState, + validationResults: serverState.validationResults.map((validation) => { + const { result } = validation + return { + ...validation, + // Omit the helpers that come with some error maps + result: + result && typeof result === 'object' && 'fields' in result + ? { form: result.form, fields: result.fields } + : result, + } + }), + } + + setCookie(INTERNALS_COOKIE_NAME, stringify(state)) } -export function getInternalTanStackCookie(): - ServerFormState | undefined { +export function getInternalTanStackCookie(): ServerFormState | undefined { const cookie = getCookie(INTERNALS_COOKIE_NAME) if (!cookie) return undefined - return parse(cookie) as ServerFormState + return parse(cookie) as ServerFormState } export function deleteInternalTanStackCookie(): void { diff --git a/packages/react-form-start/tests/start.spec.ts b/packages/react-form-start/tests/start.spec.ts index 77232d84c4..d96472bd0f 100644 --- a/packages/react-form-start/tests/start.spec.ts +++ b/packages/react-form-start/tests/start.spec.ts @@ -48,7 +48,7 @@ const formOpts = formOptions({ const errors = createErrorMap() if (!value.name.trim()) { - errors.fields.name = 'Name is required' + errors.fields.name = ['Name is required'] } return errors @@ -81,7 +81,7 @@ describe('start server validation persistence', () => { validatorIndex: 0, result: { fields: { - name: 'Name is required', + name: ['Name is required'], }, }, }, diff --git a/packages/react-form-start/tests/start.test-d.ts b/packages/react-form-start/tests/start.test-d.ts index b4983cb90d..5190e7b59b 100644 --- a/packages/react-form-start/tests/start.test-d.ts +++ b/packages/react-form-start/tests/start.test-d.ts @@ -1,17 +1,17 @@ import { describe, expectTypeOf, it } from 'vitest' -import { formOptions, serverValidateHelper } from '@tanstack/form-core' +import { formOptions } from '@tanstack/form-core' import { start } from '../src' -import type { ServerFormState, ServerValidateResult } from '@tanstack/form-core' +import type { ServerFormState } from '@tanstack/form-core' describe('start server validation types', () => { - it('preserves discriminated result generics through the helper', async () => { + it('preserves discriminated result generics through the factory', async () => { const options = formOptions({ defaultValues: { name: '' }, validators: [ { run: () => ({ fields: { - name: 'Name is required', + name: ['Name is required'], }, }), triggers: ['server'], @@ -19,9 +19,7 @@ describe('start server validation types', () => { ], }) - const { createServerValidate } = serverValidateHelper({ - framework: start(), - }) + const { createServerValidate } = start() const validate = createServerValidate(options) const result = await validate({ formData: {} as FormData }) @@ -29,11 +27,8 @@ describe('start server validation types', () => { expectTypeOf(result.values).toEqualTypeOf<{ name: string }>() } else { type Values = { name: string } - type Validators = NonNullable - expectTypeOf(result.serverState).toEqualTypeOf< - ServerFormState - >() + expectTypeOf(result.serverState).toEqualTypeOf>() } }) @@ -48,26 +43,26 @@ describe('start server validation types', () => { ], }) - const { createServerValidate, getFormData } = serverValidateHelper({ - framework: start({ - onInvalid: () => ({ status: 'invalid' as const }), - onValid: () => ({ status: 'valid' as const }), - }), + const { createServerValidate, getFormData } = start({ + onInvalid: () => ({ status: 'invalid' as const }), + onValid: () => ({ status: 'valid' as const }), }) const serverState = await getFormData() - expectTypeOf(serverState).toEqualTypeOf>() + expectTypeOf(serverState).toEqualTypeOf>() const validate = createServerValidate(options) const result = await validate({ formData: {} as FormData }) if ('success' in result) { - expectTypeOf(result).toEqualTypeOf< - ServerValidateResult< - { name: string }, - NonNullable - > - >() + if (result.success) { + expectTypeOf(result.values).toEqualTypeOf<{ name: string }>() + expectTypeOf(result.validatorOutputs).toEqualTypeOf<[undefined]>() + } else { + expectTypeOf(result.serverState).toEqualTypeOf< + ServerFormState<{ name: string }> + >() + } } else { expectTypeOf(result).toEqualTypeOf< { status: 'invalid' } | { status: 'valid' } @@ -86,9 +81,7 @@ describe('start server validation types', () => { ], }) - const { createServerValidate } = serverValidateHelper({ - framework: start(), - }) + const { createServerValidate } = start() const validate = createServerValidate(options, { info: { numbers: ['age'], @@ -112,12 +105,7 @@ describe('start server validation types', () => { }) expectTypeOf(validate.getFormData()).toEqualTypeOf< - Promise< - ServerFormState< - { firstName: string; age: number }, - NonNullable - > - > + Promise> >() }) }) diff --git a/packages/react-form-start/vite.config.ts b/packages/react-form-start/vite.config.ts index f31400d97d..e7218763d2 100644 --- a/packages/react-form-start/vite.config.ts +++ b/packages/react-form-start/vite.config.ts @@ -1,27 +1,15 @@ import { defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' -import react from '@vitejs/plugin-react' -import { playwright } from '@vitest/browser-playwright' import packageJson from './package.json' const config = defineConfig({ - plugins: [react()], - optimizeDeps: { - exclude: ['@tanstack/react-start', '@tanstack/react-start/server'], - }, test: { name: packageJson.name, dir: './tests', watch: false, globals: true, - setupFiles: ['vitest-browser-react'], - browser: { - enabled: true, - provider: playwright( - process.env.CI ? { launchOptions: { channel: 'chrome' } } : {}, - ), - instances: [{ browser: 'chromium', headless: true }], - }, + // TODO Browser testing was really weird with tanstack start plugin traversal. Perhaps we can reintroduce it sometime? + environment: 'node', coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, typecheck: { enabled: true }, }, From 255d04d9f83eb8d2d92e9777e1f3229062813490 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:04:01 +0100 Subject: [PATCH 36/43] fix: fix examples types --- .../angular/large-form/src/shared-form.ts | 20 +++++++++++++------ examples/lit/large-form/src/shared-form.ts | 20 +++++++++++++------ .../src/features/people/shared-form.tsx | 16 +++++++++------ .../src/features/people/shared-form.tsx | 16 +++++++++------ .../next-server-actions-zod/src/app/action.ts | 15 +++++--------- .../next-server-actions/src/app/action.ts | 15 +++++--------- .../src/app/shared-code.ts | 13 ++++++++---- examples/react/nextjs/app/action.ts | 15 +++++--------- examples/react/nextjs/app/shared-code.ts | 17 ++++++++++++---- .../src/utils/form-isomorphic.ts | 17 ++++++++++++---- .../src/utils/form-validator.ts | 5 +---- .../src/features/people/shared-form.ts | 16 +++++++++------ .../src/features/people/shared-form.ts | 20 +++++++++++++------ .../src/features/people/shared-form.ts | 16 +++++++++------ .../src/FormApi/formServerState.public.ts | 6 ++++++ packages/form-core/src/ssr.lib.ts | 6 ------ .../form-core/src/validation/state.public.ts | 2 +- .../form-core/tests/serverValidate.spec.ts | 4 ++-- packages/react-form-start/package.json | 4 +--- packages/react-form-start/src/getFormData.ts | 2 +- packages/react-form-start/tests/start.spec.ts | 3 +-- pnpm-lock.yaml | 6 ------ 22 files changed, 145 insertions(+), 109 deletions(-) diff --git a/examples/angular/large-form/src/shared-form.ts b/examples/angular/large-form/src/shared-form.ts index 4d407d607b..75c8248b2f 100644 --- a/examples/angular/large-form/src/shared-form.ts +++ b/examples/angular/large-form/src/shared-form.ts @@ -13,15 +13,23 @@ export const peopleFormOptions = formOptions({ triggers: ['change'], run: async ({ value, createErrorMap }) => { const errors = createErrorMap() - if (!value.fullName) errors.fields.fullName = 'Full name is required' - if (!value.phone) errors.fields.phone = 'Phone is required' + if (!value.fullName) { + errors.addFieldError('fullName', 'Full name is required') + } + if (!value.phone) { + errors.addFieldError('phone', 'Phone is required') + } if (!value.emergencyContact.fullName) { - errors.fields['emergencyContact.fullName'] = - 'Emergency contact full name is required' + errors.addFieldError( + 'emergencyContact.fullName', + 'Emergency contact full name is required', + ) } if (!value.emergencyContact.phone) { - errors.fields['emergencyContact.phone'] = - 'Emergency contact phone is required' + errors.addFieldError( + 'emergencyContact.phone', + 'Emergency contact phone is required', + ) } return errors }, diff --git a/examples/lit/large-form/src/shared-form.ts b/examples/lit/large-form/src/shared-form.ts index b9accfa25e..3662e0bff6 100644 --- a/examples/lit/large-form/src/shared-form.ts +++ b/examples/lit/large-form/src/shared-form.ts @@ -22,15 +22,23 @@ export const peopleFormOptions = formOptions({ triggers: ['change'], run: ({ value, createErrorMap }) => { const errors = createErrorMap() - if (!value.fullName) errors.fields.fullName = 'Full name is required' - if (!value.phone) errors.fields.phone = 'Phone is required' + if (!value.fullName) { + errors.addFieldError('fullName', 'Full name is required') + } + if (!value.phone) { + errors.addFieldError('phone', 'Phone is required') + } if (!value.emergencyContact.fullName) { - errors.fields['emergencyContact.fullName'] = - 'Emergency contact full name is required' + errors.addFieldError( + 'emergencyContact.fullName', + 'Emergency contact full name is required', + ) } if (!value.emergencyContact.phone) { - errors.fields['emergencyContact.phone'] = - 'Emergency contact phone is required' + errors.addFieldError( + 'emergencyContact.phone', + 'Emergency contact phone is required', + ) } return errors }, diff --git a/examples/preact/large-form/src/features/people/shared-form.tsx b/examples/preact/large-form/src/features/people/shared-form.tsx index 18afb5ffaf..81e5ecd0e5 100644 --- a/examples/preact/large-form/src/features/people/shared-form.tsx +++ b/examples/preact/large-form/src/features/people/shared-form.tsx @@ -24,18 +24,22 @@ export const peopleFormOpts = appFormOptions({ const errors = createErrorMap() if (!value.fullName) { - errors.fields.fullName = 'Full name is required' + errors.addFieldError('fullName', 'Full name is required') } if (!value.phone) { - errors.fields.phone = 'Phone is required' + errors.addFieldError('phone', 'Phone is required') } if (!value.emergencyContact.fullName) { - errors.fields['emergencyContact.fullName'] = - 'Emergency contact full name is required' + errors.addFieldError( + 'emergencyContact.fullName', + 'Emergency contact full name is required', + ) } if (!value.emergencyContact.phone) { - errors.fields['emergencyContact.phone'] = - 'Emergency contact phone is required' + errors.addFieldError( + 'emergencyContact.phone', + 'Emergency contact phone is required', + ) } return errors diff --git a/examples/react/large-form/src/features/people/shared-form.tsx b/examples/react/large-form/src/features/people/shared-form.tsx index 18afb5ffaf..81e5ecd0e5 100644 --- a/examples/react/large-form/src/features/people/shared-form.tsx +++ b/examples/react/large-form/src/features/people/shared-form.tsx @@ -24,18 +24,22 @@ export const peopleFormOpts = appFormOptions({ const errors = createErrorMap() if (!value.fullName) { - errors.fields.fullName = 'Full name is required' + errors.addFieldError('fullName', 'Full name is required') } if (!value.phone) { - errors.fields.phone = 'Phone is required' + errors.addFieldError('phone', 'Phone is required') } if (!value.emergencyContact.fullName) { - errors.fields['emergencyContact.fullName'] = - 'Emergency contact full name is required' + errors.addFieldError( + 'emergencyContact.fullName', + 'Emergency contact full name is required', + ) } if (!value.emergencyContact.phone) { - errors.fields['emergencyContact.phone'] = - 'Emergency contact phone is required' + errors.addFieldError( + 'emergencyContact.phone', + 'Emergency contact phone is required', + ) } return errors diff --git a/examples/react/next-server-actions-zod/src/app/action.ts b/examples/react/next-server-actions-zod/src/app/action.ts index b9870bd645..caf6f009ea 100644 --- a/examples/react/next-server-actions-zod/src/app/action.ts +++ b/examples/react/next-server-actions-zod/src/app/action.ts @@ -1,18 +1,13 @@ 'use server' -import { - initialServerFormState, - serverValidateHelper, -} from '@tanstack/react-form' +import { initialServerFormState } from '@tanstack/react-form' import { next } from '@tanstack/react-form-nextjs' import { formOpts } from './shared-code' -const { createServerValidate } = serverValidateHelper({ - framework: next({ - info: { - numbers: ['age'], - }, - }), +const { createServerValidate } = next({ + info: { + numbers: ['age'], + }, }) const serverValidate = createServerValidate(formOpts) diff --git a/examples/react/next-server-actions/src/app/action.ts b/examples/react/next-server-actions/src/app/action.ts index b9870bd645..caf6f009ea 100644 --- a/examples/react/next-server-actions/src/app/action.ts +++ b/examples/react/next-server-actions/src/app/action.ts @@ -1,18 +1,13 @@ 'use server' -import { - initialServerFormState, - serverValidateHelper, -} from '@tanstack/react-form' +import { initialServerFormState } from '@tanstack/react-form' import { next } from '@tanstack/react-form-nextjs' import { formOpts } from './shared-code' -const { createServerValidate } = serverValidateHelper({ - framework: next({ - info: { - numbers: ['age'], - }, - }), +const { createServerValidate } = next({ + info: { + numbers: ['age'], + }, }) const serverValidate = createServerValidate(formOpts) diff --git a/examples/react/next-server-actions/src/app/shared-code.ts b/examples/react/next-server-actions/src/app/shared-code.ts index 9d557bfa7f..d766311c0a 100644 --- a/examples/react/next-server-actions/src/app/shared-code.ts +++ b/examples/react/next-server-actions/src/app/shared-code.ts @@ -12,7 +12,10 @@ export const formOpts = formOptions({ const errors = createErrorMap() if (value.age < 8) { - errors.fields.age = 'Client validation: You must be at least 8' + errors.addFieldError( + 'age', + 'Client validation: You must be at least 8', + ) } return errors @@ -20,13 +23,15 @@ export const formOpts = formOptions({ }, { triggers: ['server'], - runOnSubmit: false, + skipOnSubmit: true, run: ({ value, createErrorMap }) => { const errors = createErrorMap() if (value.age < 12) { - errors.fields.age = - 'Server validation: You must be at least 12 to sign up' + errors.addFieldError( + 'age', + 'Server validation: You must be at least 12 to sign up', + ) } return errors diff --git a/examples/react/nextjs/app/action.ts b/examples/react/nextjs/app/action.ts index c7d05bd7df..abb475db06 100644 --- a/examples/react/nextjs/app/action.ts +++ b/examples/react/nextjs/app/action.ts @@ -1,18 +1,13 @@ 'use server' -import { - initialServerFormState, - serverValidateHelper, -} from '@tanstack/react-form' +import { initialServerFormState } from '@tanstack/react-form' import { next } from '@tanstack/react-form-nextjs' import { formOpts } from './shared-code' -const { createServerValidate } = serverValidateHelper({ - framework: next({ - info: { - numbers: ['age'], - }, - }), +const { createServerValidate } = next({ + info: { + numbers: ['age'], + }, }) const serverValidate = createServerValidate(formOpts) diff --git a/examples/react/nextjs/app/shared-code.ts b/examples/react/nextjs/app/shared-code.ts index 077402436d..d898fc11d2 100644 --- a/examples/react/nextjs/app/shared-code.ts +++ b/examples/react/nextjs/app/shared-code.ts @@ -12,7 +12,10 @@ export const formOpts = formOptions({ const errors = createErrorMap() if (value.age < 8) { - errors.fields.age = 'Client validation: You must be at least 8' + errors.addFieldError( + 'age', + 'Client validation: You must be at least 8', + ) } return errors @@ -21,16 +24,22 @@ export const formOpts = formOptions({ { triggers: ['server'], // This validator will only run on the server - runOnSubmit: false, + skipOnSubmit: true, run: ({ value, createErrorMap }) => { const errors = createErrorMap() if (!value.firstName.trim()) { - errors.fields.firstName = 'Server validation: First name is required' + errors.addFieldError( + 'firstName', + 'Server validation: First name is required', + ) } if (value.age < 13) { - errors.fields.age = 'Server validation: You must be at least 13' + errors.addFieldError( + 'age', + 'Server validation: You must be at least 13', + ) } return errors diff --git a/examples/react/tanstack-start/src/utils/form-isomorphic.ts b/examples/react/tanstack-start/src/utils/form-isomorphic.ts index 077402436d..d898fc11d2 100644 --- a/examples/react/tanstack-start/src/utils/form-isomorphic.ts +++ b/examples/react/tanstack-start/src/utils/form-isomorphic.ts @@ -12,7 +12,10 @@ export const formOpts = formOptions({ const errors = createErrorMap() if (value.age < 8) { - errors.fields.age = 'Client validation: You must be at least 8' + errors.addFieldError( + 'age', + 'Client validation: You must be at least 8', + ) } return errors @@ -21,16 +24,22 @@ export const formOpts = formOptions({ { triggers: ['server'], // This validator will only run on the server - runOnSubmit: false, + skipOnSubmit: true, run: ({ value, createErrorMap }) => { const errors = createErrorMap() if (!value.firstName.trim()) { - errors.fields.firstName = 'Server validation: First name is required' + errors.addFieldError( + 'firstName', + 'Server validation: First name is required', + ) } if (value.age < 13) { - errors.fields.age = 'Server validation: You must be at least 13' + errors.addFieldError( + 'age', + 'Server validation: You must be at least 13', + ) } return errors diff --git a/examples/react/tanstack-start/src/utils/form-validator.ts b/examples/react/tanstack-start/src/utils/form-validator.ts index e00a3903eb..b16a490563 100644 --- a/examples/react/tanstack-start/src/utils/form-validator.ts +++ b/examples/react/tanstack-start/src/utils/form-validator.ts @@ -1,6 +1,3 @@ -import { serverValidateHelper } from '@tanstack/react-form' import { start } from '@tanstack/react-form-start' -export const { createServerValidate } = serverValidateHelper({ - framework: start(), -}) +export const { createServerValidate } = start() diff --git a/examples/solid/large-form/src/features/people/shared-form.ts b/examples/solid/large-form/src/features/people/shared-form.ts index 512fda403b..44df9f9730 100644 --- a/examples/solid/large-form/src/features/people/shared-form.ts +++ b/examples/solid/large-form/src/features/people/shared-form.ts @@ -24,18 +24,22 @@ export const peopleFormOpts = appFormOptions({ const errors = createErrorMap() if (!value.fullName) { - errors.fields.fullName = 'Full name is required' + errors.addFieldError('fullName', 'Full name is required') } if (!value.phone) { - errors.fields.phone = 'Phone is required' + errors.addFieldError('phone', 'Phone is required') } if (!value.emergencyContact.fullName) { - errors.fields['emergencyContact.fullName'] = - 'Emergency contact full name is required' + errors.addFieldError( + 'emergencyContact.fullName', + 'Emergency contact full name is required', + ) } if (!value.emergencyContact.phone) { - errors.fields['emergencyContact.phone'] = - 'Emergency contact phone is required' + errors.addFieldError( + 'emergencyContact.phone', + 'Emergency contact phone is required', + ) } return errors diff --git a/examples/svelte/large-form/src/features/people/shared-form.ts b/examples/svelte/large-form/src/features/people/shared-form.ts index ed6d09c6d5..e622985400 100644 --- a/examples/svelte/large-form/src/features/people/shared-form.ts +++ b/examples/svelte/large-form/src/features/people/shared-form.ts @@ -22,15 +22,23 @@ export const peopleFormOpts = appFormOptions({ triggers: ['change'], run: async ({ value, createErrorMap }) => { const errors = createErrorMap() - if (!value.fullName) errors.fields.fullName = 'Full name is required' - if (!value.phone) errors.fields.phone = 'Phone is required' + if (!value.fullName) { + errors.addFieldError('fullName', 'Full name is required') + } + if (!value.phone) { + errors.addFieldError('phone', 'Phone is required') + } if (!value.emergencyContact.fullName) { - errors.fields['emergencyContact.fullName'] = - 'Emergency contact full name is required' + errors.addFieldError( + 'emergencyContact.fullName', + 'Emergency contact full name is required', + ) } if (!value.emergencyContact.phone) { - errors.fields['emergencyContact.phone'] = - 'Emergency contact phone is required' + errors.addFieldError( + 'emergencyContact.phone', + 'Emergency contact phone is required', + ) } return errors }, diff --git a/examples/vue/large-form/src/features/people/shared-form.ts b/examples/vue/large-form/src/features/people/shared-form.ts index b9e8be3926..a93b66bc55 100644 --- a/examples/vue/large-form/src/features/people/shared-form.ts +++ b/examples/vue/large-form/src/features/people/shared-form.ts @@ -24,18 +24,22 @@ export const peopleFormOpts = appFormOptions({ const errors = createErrorMap() if (!value.fullName) { - errors.fields.fullName = 'Full name is required' + errors.addFieldError('fullName', 'Full name is required') } if (!value.phone) { - errors.fields.phone = 'Phone is required' + errors.addFieldError('phone', 'Phone is required') } if (!value.emergencyContact.fullName) { - errors.fields['emergencyContact.fullName'] = - 'Emergency contact full name is required' + errors.addFieldError( + 'emergencyContact.fullName', + 'Emergency contact full name is required', + ) } if (!value.emergencyContact.phone) { - errors.fields['emergencyContact.phone'] = - 'Emergency contact phone is required' + errors.addFieldError( + 'emergencyContact.phone', + 'Emergency contact phone is required', + ) } return errors diff --git a/packages/form-core/src/FormApi/formServerState.public.ts b/packages/form-core/src/FormApi/formServerState.public.ts index d479dd44ff..083824c7e3 100644 --- a/packages/form-core/src/FormApi/formServerState.public.ts +++ b/packages/form-core/src/FormApi/formServerState.public.ts @@ -2,6 +2,12 @@ import type { ServerSideEvents } from '../events.public' import type { FormValidationResult } from '../validation/state.public' import type { GetValidatorOutputs } from './formSubmit.public' +export const initialServerFormState: ServerFormState = { + values: undefined, + validationResults: [], + submissionAttempts: 0, +} + export interface ServerFormState { values: TFormData | undefined validationResults: Array<{ diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index a7a44828df..c09519e5eb 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -18,12 +18,6 @@ import type { } from './FormApi/formServerState.public' import type { FormOptions } from './FormApi/formOptions.public' -export const initialServerFormState: ServerFormState = { - values: undefined, - validationResults: [], - submissionAttempts: 0, -} - function createInitialFormErrorMeta(): FormErrorMeta { return { validationSourceErrors: null, diff --git a/packages/form-core/src/validation/state.public.ts b/packages/form-core/src/validation/state.public.ts index fae2b7cd17..5d2d6cc553 100644 --- a/packages/form-core/src/validation/state.public.ts +++ b/packages/form-core/src/validation/state.public.ts @@ -4,7 +4,7 @@ import type { StandardSchemaV1, StandardSchemaV1Issue, } from '../standardSchema.public' -import type { OneOrMany, ReadonlyOneOrMany } from '../types.public' +import type { ReadonlyOneOrMany } from '../types.public' import type { VALIDATION_OUTPUT } from '../validationOutput.lib' import type { PARSED_ISSUES } from './parseIssues.lib' diff --git a/packages/form-core/tests/serverValidate.spec.ts b/packages/form-core/tests/serverValidate.spec.ts index dcde21d7ee..e56627d12a 100644 --- a/packages/form-core/tests/serverValidate.spec.ts +++ b/packages/form-core/tests/serverValidate.spec.ts @@ -3,8 +3,8 @@ import { z } from 'zod' import { InternalFormApi } from '../src/FormApi/FormApi.lib' import { InternalValidatorInstance } from '../src/ValidatorInstance.lib' import { installDevtoolsBridge } from '../src/devtoolsBridge.lib' -import { initialServerFormState, validateServerValues } from '../src/internals' -import { formOptions } from '../src' +import { validateServerValues } from '../src/internals' +import { formOptions, initialServerFormState } from '../src' import type { ServerValidateFailure, ServerValidateResult, diff --git a/packages/react-form-start/package.json b/packages/react-form-start/package.json index aa69c3cada..299685a3b0 100644 --- a/packages/react-form-start/package.json +++ b/packages/react-form-start/package.json @@ -47,9 +47,7 @@ "devalue": "^5.3.2" }, "devDependencies": { - "@tanstack/react-start": "^1.168.40", - "@vitejs/plugin-react": "^6.1.1", - "vitest-browser-react": "^2.3.0" + "@tanstack/react-start": "^1.168.40" }, "peerDependencies": { "@tanstack/react-start": "^1.134.9" diff --git a/packages/react-form-start/src/getFormData.ts b/packages/react-form-start/src/getFormData.ts index eeedf4ee5f..e3e07cb46d 100644 --- a/packages/react-form-start/src/getFormData.ts +++ b/packages/react-form-start/src/getFormData.ts @@ -1,5 +1,5 @@ +import { initialServerFormState } from '@tanstack/form-core' import { createServerFn } from '@tanstack/react-start' -import { initialServerFormState } from '@tanstack/form-core/internals' import { deleteInternalTanStackCookie, getInternalTanStackCookie, diff --git a/packages/react-form-start/tests/start.spec.ts b/packages/react-form-start/tests/start.spec.ts index d96472bd0f..e67e7219d2 100644 --- a/packages/react-form-start/tests/start.spec.ts +++ b/packages/react-form-start/tests/start.spec.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { parse } from 'devalue' -import { formOptions } from '@tanstack/form-core' -import { initialServerFormState } from '@tanstack/form-core/internals' +import { formOptions, initialServerFormState } from '@tanstack/form-core' import { getFormData, start } from '../src' const startMocks = vi.hoisted(() => ({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2922e46412..2cb01cf971 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2459,12 +2459,6 @@ importers: '@tanstack/react-start': specifier: ^1.168.40 version: 1.168.40(crossws@0.4.10(srvx@0.12.5))(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rolldown@1.2.3)(rollup@4.62.4)(supports-color@7.2.0)(vite-plugin-solid@2.11.14(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.1))(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.3.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1(supports-color@7.2.0))(sass@1.102.0)(terser@5.49.0)(yaml@2.9.0)))(vite@8.3.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1(supports-color@7.2.0))(sass@1.102.0)(terser@5.49.0)(yaml@2.9.0)) - '@vitejs/plugin-react': - specifier: ^6.1.1 - version: 6.1.1(babel-plugin-react-compiler@1.0.0)(vite@8.3.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1(supports-color@7.2.0))(sass@1.102.0)(terser@5.49.0)(yaml@2.9.0)) - vitest-browser-react: - specifier: ^2.3.0 - version: 2.3.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.1) packages/solid-form: dependencies: From 04f2cfb5a62538d1b0132bc3a007da969102c56b Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:04:19 +0100 Subject: [PATCH 37/43] chore: apply fixes --- packages/form-core/src/index.ts | 1 - .../form-core/src/new-validation.public.ts | 20 -------- .../src/validation/formValidators.public.ts | 19 ++++--- packages/react-form-nextjs/src/index.ts | 50 +++++++++---------- 4 files changed, 32 insertions(+), 58 deletions(-) delete mode 100644 packages/form-core/src/new-validation.public.ts diff --git a/packages/form-core/src/index.ts b/packages/form-core/src/index.ts index 6ba2c34480..f99059c2ad 100644 --- a/packages/form-core/src/index.ts +++ b/packages/form-core/src/index.ts @@ -13,7 +13,6 @@ export * from './types.public' export * from './validation.public' export * from './validation/formValidators.public' export * from './validation/state.public' -export * from './new-validation.public' export * from './standardSchema.public' export * from './listeners.public' export * from './events.public' diff --git a/packages/form-core/src/new-validation.public.ts b/packages/form-core/src/new-validation.public.ts deleted file mode 100644 index ff1fa52e36..0000000000 --- a/packages/form-core/src/new-validation.public.ts +++ /dev/null @@ -1,20 +0,0 @@ -import z from 'zod' -import { formOptions } from './FormApi/formOptions.public' - -// TODO remove - -const test = false as boolean - -const schema = z.object({ name: z.string() }).transform((d) => d.name.length) - -const opts = formOptions({ - defaultValues: { name: '' }, - validators: [ - { - run: () => {}, - }, - ], - onSubmit: ({ validatorOutputs }) => { - const [schemaOutput] = validatorOutputs - }, -}) diff --git a/packages/form-core/src/validation/formValidators.public.ts b/packages/form-core/src/validation/formValidators.public.ts index 44432e54c0..7fd24eb250 100644 --- a/packages/form-core/src/validation/formValidators.public.ts +++ b/packages/form-core/src/validation/formValidators.public.ts @@ -69,9 +69,12 @@ interface BaseFormValidatorContext { */ createErrorMap: CreateErrorMapFn /** - * Converts Standard Schema issues into a mutable error map. All issues appear - * in `form`, and issues with paths are also grouped in `fields` using the - * values being validated. Return the map to report its errors. + * Converts Standard Schema issues into a mutable error map to return from + * this validator. + * + * By default, issues with field paths are grouped in `fields`, and issues + * without a field path go in `form`. The validator's `fieldErrorTarget` + * option can report field issues in both places or only in `form`. * * @example * ```ts @@ -273,10 +276,6 @@ export type ParseValidatorCondition = TCondition extends ( ? boolean : TCondition -// TODO: When wiring up validation, apply fieldErrorTarget to schema issues; -// default to 'field' and always keep root issues in the form, including paths -// normalized to empty. Warn once during configuration in development if a -// callback supplies the option; ignore it for callbacks in every build. /** * Provides the function or Standard Schema used to validate complete form values. * @@ -295,8 +294,8 @@ interface FormValidatorRunner { StandardSchemaV1 | FormValidatorFn /** - * Selects where this schema's issues with field paths are reported. - * This setting does nothing for callback validators. + * Selects where issues with field paths from a Standard Schema or + * `parseIssues` are reported. * * - `'field'`: Report issues on their corresponding fields. * - `'field-and-self'`: Report issues on their fields and the form. @@ -465,7 +464,7 @@ interface FormValidatorConfig< * { * skipOnSubmit: isDraft, * run: ({ value }) => { - * if (!value.email.includes('@)) { + * if (!value.email.includes('@')) { * return 'Enter a valid email' * } * } diff --git a/packages/react-form-nextjs/src/index.ts b/packages/react-form-nextjs/src/index.ts index 217fb55e5f..0bfd751322 100644 --- a/packages/react-form-nextjs/src/index.ts +++ b/packages/react-form-nextjs/src/index.ts @@ -1,10 +1,6 @@ import { decode } from 'decode-formdata' import { validateServerValues } from '@tanstack/form-core/internals' -import type { - AnyFormOptions, - FormOptions, - ServerValidateResult, -} from '@tanstack/form-core' +import type { FormOptions, ServerValidateResult } from '@tanstack/form-core' import type { FormDataInfo } from 'decode-formdata' export type NextServerValidate = ( @@ -15,21 +11,6 @@ export interface NextServerValidateOptions { info?: FormDataInfo } -type NextCreateServerValidate = < - TFormData, - const TValidatorConfig extends unknown[], - TValidators, - TSubmitReturn, ->( - options: FormOptions< - TFormData, - TValidatorConfig, - TValidators, - TSubmitReturn, - unknown - >, -) => NextServerValidate - function decodeFormData( formData: FormData, info: FormDataInfo | undefined, @@ -38,14 +19,29 @@ function decodeFormData( } export function next(options: NextServerValidateOptions = {}) { + const createServerValidate = < + TFormData, + const TValidatorConfig extends unknown[], + TValidators, + TSubmitReturn, + >( + formOptions: FormOptions< + TFormData, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, + ): NextServerValidate => { + return async (formData: FormData) => { + const values = decodeFormData(formData, options.info) + + return validateServerValues(formOptions, values) + } + } + return { id: 'react-form-nextjs' as const, - createServerValidate: ((formOptions: AnyFormOptions) => { - return async (formData: FormData) => { - const values = decodeFormData(formData, options.info) - - return validateServerValues(formOptions, values) - } - }) satisfies NextCreateServerValidate, + createServerValidate, } } From 1c812638ebcc3c5274196d22a31b296d34cc26bf Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:26:08 +0100 Subject: [PATCH 38/43] chore: add unit tests for fieldErrorTarget --- .../src/validation/formValidators.public.ts | 1 + .../tests/FormApi/FormApi.lib.spec.ts | 172 ++++++++++++++++++ .../tests/validation/parseIssues.lib.spec.ts | 85 +++++++++ 3 files changed, 258 insertions(+) create mode 100644 packages/form-core/tests/validation/parseIssues.lib.spec.ts diff --git a/packages/form-core/src/validation/formValidators.public.ts b/packages/form-core/src/validation/formValidators.public.ts index 7fd24eb250..0150ad0ab0 100644 --- a/packages/form-core/src/validation/formValidators.public.ts +++ b/packages/form-core/src/validation/formValidators.public.ts @@ -307,6 +307,7 @@ interface FormValidatorRunner { * * @default 'field' */ + // TODO: This should probably apply to all validation errors, not only schema issues. fieldErrorTarget?: 'field' | 'field-and-self' | 'self' /** * If `true`, skips this validator and all later validators when earlier diff --git a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts index fa81ec62ef..816401a26b 100644 --- a/packages/form-core/tests/FormApi/FormApi.lib.spec.ts +++ b/packages/form-core/tests/FormApi/FormApi.lib.spec.ts @@ -1,8 +1,180 @@ import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' import { InternalFormApi } from '../../src/FormApi/FormApi.lib' import { formOptions } from '../../src' import { promiseWithResolvers } from '../test-utils' +describe('InternalFormApi - fieldErrorTarget', () => { + const nameIssue = expect.objectContaining({ + message: 'Name is required', + path: ['name'], + }) + + describe.each([ + { + fieldErrorTarget: undefined, + formErrors: [], + fieldErrors: [nameIssue], + }, + { + fieldErrorTarget: 'field', + formErrors: [], + fieldErrors: [nameIssue], + }, + { + fieldErrorTarget: 'field-and-self', + formErrors: [nameIssue], + fieldErrors: [nameIssue], + }, + { + fieldErrorTarget: 'self', + formErrors: [nameIssue], + fieldErrors: [], + }, + ] as const)('target $fieldErrorTarget', (ctx) => { + const { fieldErrorTarget, formErrors, fieldErrors } = ctx + + it('routes Standard Schema issues when a field changes', async () => { + const form = new InternalFormApi({ + defaultValues: { name: 'Alice' }, + validators: [ + { + run: z.object({ name: z.string().min(1, 'Name is required') }), + triggers: ['change'], + fieldErrorTarget, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + field.handleChange('') + + await expect + .poll(() => ({ + formErrors: form.state.errors, + fieldErrors: field.errors, + })) + .toEqual({ formErrors, fieldErrors }) + expect(form.state.isValid).toBe(false) + }) + + it('routes Standard Schema issues when the form is submitted', async () => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + run: z.object({ name: z.string().min(1, 'Name is required') }), + fieldErrorTarget, + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + await form.handleSubmit() + + expect(form.state.errors).toEqual(formErrors) + expect(field.errors).toEqual(fieldErrors) + expect(form.state.isValid).toBe(false) + }) + }) + + it('routes Standard Schema mount issues to both the field and the form', async ({ + onTestFinished, + }) => { + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + run: z.object({ name: z.string().min(1, 'Name is required') }), + triggers: ['mount'], + fieldErrorTarget: 'field-and-self', + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + const unmount = form.mount() + onTestFinished(unmount) + + await expect.poll(() => form.state.errors).toEqual([nameIssue]) + expect(field.errors).toEqual([nameIssue]) + }) + + it('routes manually parsed schema mount issues only to the form with target self', ({ + onTestFinished, + }) => { + const schema = z.object({ name: z.string().min(1, 'Name is required') }) + + const form = new InternalFormApi({ + defaultValues: { name: '' }, + validators: [ + { + run: ({ value, parseIssues }) => { + const result = schema.safeParse(value) + if (result.success) return + return parseIssues(result.error.issues) + }, + triggers: ['mount'], + fieldErrorTarget: 'self', + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + const unmount = form.mount() + onTestFinished(unmount) + + expect(form.state.errors).toEqual([nameIssue]) + expect(field.errors).toEqual([]) + }) + + it('reroutes errors after updates and restores field routing when the option is removed', async () => { + const schema = z.object({ name: z.string().min(1, 'Name is required') }) + + const options = formOptions({ + defaultValues: { name: '' }, + validators: [{ run: schema, triggers: ['change'] }], + }) + + const form = new InternalFormApi({ + ...options, + validators: [ + { + run: schema, + triggers: ['change'], + fieldErrorTarget: 'field-and-self', + }, + ], + }) + const field = form._getOrCreateFieldApi({ name: 'name' }) + field._register() + + await form.handleSubmit() + expect(form.state.errors).toEqual([nameIssue]) + expect(field.errors).toEqual([nameIssue]) + + form._update({ + ...options, + validators: [ + { run: schema, triggers: ['change'], fieldErrorTarget: 'self' }, + ], + }) + await form.handleSubmit() + expect(form.state.errors).toEqual([nameIssue]) + expect(field.errors).toEqual([]) + + form._update(options) + await form.handleSubmit() + + expect(form.state.errors).toEqual([]) + expect(field.errors).toEqual([nameIssue]) + }) +}) + describe('InternalFormApi - constructor', () => { it('initializes default values with untouched, submittable state', () => { const form = new InternalFormApi({ diff --git a/packages/form-core/tests/validation/parseIssues.lib.spec.ts b/packages/form-core/tests/validation/parseIssues.lib.spec.ts new file mode 100644 index 0000000000..64b2f761df --- /dev/null +++ b/packages/form-core/tests/validation/parseIssues.lib.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { + PARSED_ISSUES, + parseIssuesFactory, +} from '../../src/validation/parseIssues.lib' + +describe('parseIssuesFactory - fieldErrorTarget', () => { + it('routes field issues to their paths and keeps pathless issues on the form', () => { + const nameIssue = { message: 'Name is required', path: ['name'] } + const otherNameIssue = { message: 'Name is too short', path: ['name'] } + const itemIssue = { + message: 'Item name is required', + path: [{ key: 'items' }, { key: '0' }, { key: 'name' }], + } + const formIssue = { message: 'Check the form' } + const emptyPathIssue = { message: 'Empty path', path: [] } + const normalizedEmptyPathIssue = { + message: 'Path normalizes to empty', + path: ['', { key: '' }], + } + const parseIssues = parseIssuesFactory( + { name: '', items: [{ name: '' }] }, + 'field', + ) + + const result = parseIssues([ + nameIssue, + formIssue, + otherNameIssue, + itemIssue, + emptyPathIssue, + normalizedEmptyPathIssue, + ]) + + expect(result).toMatchObject({ + form: [formIssue, emptyPathIssue, normalizedEmptyPathIssue], + fields: { + name: [nameIssue, otherNameIssue], + 'items[0].name': [itemIssue], + }, + }) + }) + + it('routes issues to both their fields and the form in their original order', () => { + const nameIssue = { message: 'Name is required', path: ['name'] } + const formIssue = { message: 'Check the form' } + const otherNameIssue = { message: 'Name is too short', path: ['name'] } + const parseIssues = parseIssuesFactory({ name: '' }, 'field-and-self') + + const result = parseIssues([nameIssue, formIssue, otherNameIssue]) + + expect(result).toMatchObject({ + form: [nameIssue, formIssue, otherNameIssue], + fields: { name: [nameIssue, otherNameIssue] }, + }) + }) + + it('routes all issues only to the form when the target is self', () => { + const nameIssue = { message: 'Name is required', path: ['name'] } + const formIssue = { message: 'Check the form' } + const emailIssue = { message: 'Invalid email', path: ['email'] } + const parseIssues = parseIssuesFactory({ name: '', email: '' }, 'self') + + const result = parseIssues([nameIssue, formIssue, emailIssue]) + + expect(result).toMatchObject({ + form: [nameIssue, formIssue, emailIssue], + fields: {}, + }) + }) + + it.each(['field', 'field-and-self', 'self'] as const)( + 'returns an empty error map for no issues with target %s', + (fieldErrorTarget) => { + const parseIssues = parseIssuesFactory({ name: '' }, fieldErrorTarget) + + const result = parseIssues([]) + + expect(result).toMatchObject({ + form: [], + fields: {}, + }) + }, + ) +}) From 8f1b844aefa0a34d88143b8a2a997c157c56b9db Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:29:46 +0100 Subject: [PATCH 39/43] chore: add changeset --- .changeset/clear-turtles-play.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/clear-turtles-play.md diff --git a/.changeset/clear-turtles-play.md b/.changeset/clear-turtles-play.md new file mode 100644 index 0000000000..a1512044d0 --- /dev/null +++ b/.changeset/clear-turtles-play.md @@ -0,0 +1,21 @@ +--- +'@tanstack/angular-form': minor +'@tanstack/form-core': minor +'@tanstack/form-devtools': minor +'@tanstack/lit-form': minor +'@tanstack/preact-form': minor +'@tanstack/react-form': minor +'@tanstack/react-form-nextjs': minor +'@tanstack/react-form-start': minor +'@tanstack/solid-form': minor +'@tanstack/svelte-form': minor +'@tanstack/vue-form': minor +--- + +BREAKING: Schemas no longer automatically send copies of field errors to the form. Use `fieldErrorTarget: 'field-and-self'` on form or group validators. + +`fieldErrorTarget` can now control where schemas process errors. It has three modes: + +- `'field'` (default) - Field errors are only sent to the field it targets. +- `'field-and-self'` - Field errors are sent to the field it targets, and a copy is sent to the form array. +- `'self'` - Fields do not receive errors, only the form. From f82f1d4c971273f840ce4a1ee21f472dc5e3f100 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:45:58 +0100 Subject: [PATCH 40/43] fix: tighten loose unit test check --- .../form-core/tests/validation/parseIssues.lib.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/form-core/tests/validation/parseIssues.lib.spec.ts b/packages/form-core/tests/validation/parseIssues.lib.spec.ts index 64b2f761df..5a865ff351 100644 --- a/packages/form-core/tests/validation/parseIssues.lib.spec.ts +++ b/packages/form-core/tests/validation/parseIssues.lib.spec.ts @@ -67,6 +67,10 @@ describe('parseIssuesFactory - fieldErrorTarget', () => { form: [nameIssue, formIssue, emailIssue], fields: {}, }) + // Apparently, toMatchObject() with {} is valid. + // Probably a good reason for that, but we need this + // to strictly match. + expect(result.fields).toEqual({}) }) it.each(['field', 'field-and-self', 'self'] as const)( @@ -80,6 +84,10 @@ describe('parseIssuesFactory - fieldErrorTarget', () => { form: [], fields: {}, }) + // Apparently, toMatchObject() with {} is valid. + // Probably a good reason for that, but we need this + // to strictly match. + expect(result.fields).toEqual({}) }, ) }) From 0eade7d0eab6a57c033a559e2dcb5d6cfdfe82cc Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:22:02 +0100 Subject: [PATCH 41/43] fix: reduce instantiations in form groups --- .../src/FormGroupApi/FormGroupApi.public.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts index 7cfa519a51..9bf6860131 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts @@ -141,11 +141,18 @@ export interface FormGroupOptions< > { form: FormApi name: TGroupName - validators?: TGroupValidators & { - [K in keyof TGroupValidators]: Record< - Exclude>, - never - > + validators?: { + // The intersection really helps type instantiations, while + // the exclude part helps with additional properties not being swallowed silently. + // It's weird, but it works. + [K in keyof TGroupValidators]: TGroupValidators[K] & + Record< + Exclude< + keyof TGroupValidators[K], + keyof FormGroupValidator + >, + never + > } /** * Called after group submission validation succeeds. The callback is awaited From b708fd858b0a71df869316773e0b5427e56fce7f Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:19:06 +0100 Subject: [PATCH 42/43] fix: clean up duplicate types and inference --- packages/angular-form/src/form-type.ts | 11 +- .../form-core/src/FormApi/FormApi.public.ts | 11 +- .../src/FormApi/formOptions.public.ts | 41 +++--- .../src/validation/formValidators.public.ts | 25 ++-- .../form-core/src/validation/state.public.ts | 7 ++ packages/lit-form/src/get-form-type.ts | 8 +- .../src/PreactForm/formType.public.ts | 14 +-- .../src/ReactForm/formType.public.ts | 15 +-- .../SharedSubmitErrors.test-d.tsx | 42 +++++++ .../form-options/ValidatorOutputs.test-d.tsx | 117 ++++++++++++++++++ .../public/shared-form/WithValidators.tsx | 81 ++++++++++++ .../public/shared-form/WithoutValidators.tsx | 41 ++++++ packages/solid-form/src/formType.public.ts | 8 +- packages/svelte-form/src/formType.public.ts | 8 +- .../vue-form/src/VueForm/formType.public.ts | 9 +- 15 files changed, 350 insertions(+), 88 deletions(-) create mode 100644 packages/react-form/tests/public/form-options/SharedSubmitErrors.test-d.tsx create mode 100644 packages/react-form/tests/public/form-options/ValidatorOutputs.test-d.tsx create mode 100644 packages/react-form/tests/public/shared-form/WithValidators.tsx create mode 100644 packages/react-form/tests/public/shared-form/WithoutValidators.tsx diff --git a/packages/angular-form/src/form-type.ts b/packages/angular-form/src/form-type.ts index 9c8fb85fcd..9f7a3df718 100644 --- a/packages/angular-form/src/form-type.ts +++ b/packages/angular-form/src/form-type.ts @@ -3,16 +3,9 @@ import type { AnyFormOptions, FormApi, FormOptions, - GetFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' -// Reusable formOptions often omit onSubmit so a component can provide it later. -// Keep that unresolved submit return flexible when the form is passed to a child. -type AngularFormTypeErrorTypes = - unknown extends TSubmitReturn - ? any - : GetFormErrorTypes - /** * An Angular form API whose form data, validator, and submit types are erased. * @@ -65,5 +58,5 @@ export type AngularFormType = infer TSubmitReturn, any > - ? FormApi> + ? FormApi> : never diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index 89c019f23f..24244ac3cc 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -4,9 +4,15 @@ import type { FormApiFieldMethods } from './FormApiFieldMethods.types.public' import type { FormValidationFailure, ScopedErrorTypes, + ValidationIssue, } from '../validation/state.public' import type { DynamicValidatorTriggers } from '../events.public' +type FormError = + TFormError extends ScopedErrorTypes + ? TError + : never + /** * A snapshot of current values, validation status, and submission metadata. * @@ -18,8 +24,7 @@ import type { DynamicValidatorTriggers } from '../events.public' */ export interface FormState< in out TFormData, - // TODO in out - TFormError extends ScopedErrorTypes, + out TFormError extends ScopedErrorTypes, > { /** * Current values after field edits, resets, or default-value updates. @@ -69,7 +74,7 @@ export interface FormState< * Errors routed to fields are exposed through the corresponding field APIs * and are not included here. */ - errors: Array + errors: Array> /** * Whether validation state contains no form- or field-level errors. * diff --git a/packages/form-core/src/FormApi/formOptions.public.ts b/packages/form-core/src/FormApi/formOptions.public.ts index b09b84bb4a..bc4e775dc5 100644 --- a/packages/form-core/src/FormApi/formOptions.public.ts +++ b/packages/form-core/src/FormApi/formOptions.public.ts @@ -21,10 +21,22 @@ type TryGetValidatorOutputs = ? GetValidatorOutputs : [] -// Spreading `formOptions` results in things like `useForm` breaks when no validator was -// provided. We have to default to [] to make it work properly. -type InferredValidatorConfig = - TValidators extends ReadonlyArray ? TValidatorConfig : [] +// Validators need context when the options are first made. However, the same context +// can break when spreading or reusing form options. +// This type removes the constraint so that it keeps playing nice when spread. +interface InferredFormOptions< + TFormData, + TValidators, + TSubmitReturn, + out TPayload, +> extends Omit< + FormOptions, + 'validators' +> { + validators?: NoInfer< + TValidators & (TValidators extends ReadonlyArray ? unknown : []) + > +} /** * Configures a form's initial values, validation, listeners, and submission. @@ -251,9 +263,8 @@ export type FormOptionsStrictSchemaFn = < TSubmitReturn, unknown >, -) => FormOptions< +) => InferredFormOptions< NoInfer>, - InferredValidatorConfig, TValidators, TSubmitReturn, TPayload @@ -305,9 +316,8 @@ export type FormOptionsLooseSchemaFn = < TValidators, TSubmitReturn >, -) => FormOptions< +) => InferredFormOptions< InferUnion>, - InferredValidatorConfig, TValidators, TSubmitReturn, TPayload @@ -341,13 +351,7 @@ export interface FormOptionsApi { TSubmitReturn, unknown >, - ): FormOptions< - TFormData, - InferredValidatorConfig, - TValidators, - TSubmitReturn, - TPayload - > + ): InferredFormOptions /** * Defines form options whose values follow a Standard Schema's input type. @@ -419,4 +423,9 @@ formOptions.looseSchema = (_schema, opts) => opts as never * Use this alias only when reusable code does not need type-safe access to * values, validators, or submission results. */ -export type AnyFormOptions = FormOptions +export type AnyFormOptions = Omit< + FormOptions, + 'onSubmit' +> & { + onSubmit?: FormOptionsOnSubmitFn +} diff --git a/packages/form-core/src/validation/formValidators.public.ts b/packages/form-core/src/validation/formValidators.public.ts index 0150ad0ab0..909f05e4ae 100644 --- a/packages/form-core/src/validation/formValidators.public.ts +++ b/packages/form-core/src/validation/formValidators.public.ts @@ -544,16 +544,21 @@ type GetValidatorRunner< TValidatorConfig extends unknown[], TValidators, K extends keyof TValidatorConfig, -> = number extends TValidatorConfig['length'] - ? FormValidatorRunner< - TFormData, - NoInfer> - > - : FormOptionsValidatorRunner< - TFormData, - NoInfer>, - K extends keyof TValidators ? TValidators[K] : unknown - > + // Types like `ReactFormType` don't care about validator configs. However, if left at + // `any`, it can mess with the inference. We simplify to "run must be present, can be whatever". + // TValidators will narrow the rest. +> = 0 extends 1 & TValidatorConfig + ? { run: unknown } + : number extends TValidatorConfig['length'] + ? FormValidatorRunner< + TFormData, + NoInfer> + > + : FormOptionsValidatorRunner< + TFormData, + NoInfer>, + K extends keyof TValidators ? TValidators[K] : unknown + > type FormOptionsValidator< TFormData, diff --git a/packages/form-core/src/validation/state.public.ts b/packages/form-core/src/validation/state.public.ts index 5d2d6cc553..cc8b623871 100644 --- a/packages/form-core/src/validation/state.public.ts +++ b/packages/form-core/src/validation/state.public.ts @@ -228,3 +228,10 @@ export type GetFormErrorTypes = ScopedErrorTypes< GetSelfErrors, GetSubfieldErrors > + +// Shared form types must accept submission handlers supplied later. Leave their +// error types open when onSubmit is omitted; concrete forms use GetFormErrorTypes. +export type GetSharedFormErrorTypes = + unknown extends TSubmitReturn + ? any + : GetFormErrorTypes diff --git a/packages/lit-form/src/get-form-type.ts b/packages/lit-form/src/get-form-type.ts index 80de0b503f..b79b17689e 100644 --- a/packages/lit-form/src/get-form-type.ts +++ b/packages/lit-form/src/get-form-type.ts @@ -2,14 +2,10 @@ import type { AnyFormOptions, FormOptions, GetFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { TanStackFormController } from './tanstack-form-controller.js' -type LitFormTypeErrorTypes = - unknown extends TSubmitReturn - ? any - : GetFormErrorTypes - /** * A Lit form controller whose form data, validator, and submit types are * erased. @@ -75,6 +71,6 @@ export type LitFormType = any, any, any, - LitFormTypeErrorTypes + GetSharedFormErrorTypes > : never diff --git a/packages/preact-form/src/PreactForm/formType.public.ts b/packages/preact-form/src/PreactForm/formType.public.ts index 47ceaac126..45a6d1055e 100644 --- a/packages/preact-form/src/PreactForm/formType.public.ts +++ b/packages/preact-form/src/PreactForm/formType.public.ts @@ -2,6 +2,7 @@ import type { AnyFormOptions, FormOptions, GetFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { AnyPreactFormComponentMap, @@ -9,17 +10,6 @@ import type { } from '../AppForm/componentMap.public' import type { PreactFormApi } from './formApiTypes.public' -// When defining formOptions, `onSubmit` is most likely not going to be present in it. Users -// prefer to define them in components. -// That's an issue for us because the inferred form error types are covariant, so resolving them -// early can break passing forms as props to child components. -// This must remain `any` so that `form={form}` remains working. There are type tests for it, so feel -// free to tinker. -type PreactFormTypeErrorTypes = - unknown extends TSubmitReturn - ? any - : GetFormErrorTypes - /** * Derives the Preact form API type represented by a reusable options object. * @@ -59,7 +49,7 @@ export type PreactFormType = > ? PreactFormApi< TFormData, - PreactFormTypeErrorTypes, + GetSharedFormErrorTypes, TComponents extends AnyPreactFormComponentMap ? TComponents : DefaultPreactFormComponentMap diff --git a/packages/react-form/src/ReactForm/formType.public.ts b/packages/react-form/src/ReactForm/formType.public.ts index f5f69a10b3..8ba7f23050 100644 --- a/packages/react-form/src/ReactForm/formType.public.ts +++ b/packages/react-form/src/ReactForm/formType.public.ts @@ -1,7 +1,7 @@ import type { AnyFormOptions, FormOptions, - GetFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { AnyReactFormComponentMap, @@ -9,17 +9,6 @@ import type { } from '../AppForm/componentMap.public' import type { ReactFormApi } from './formApiTypes.public' -// When defining formOptions, `onSubmit` is most likely not going to be present in it. Users -// prefer to define them in components. -// That's an issue for us because the inferred form error types are covariant, so resolving them -// early can break passing forms as props to child components. -// This must remain `any` so that `form={form}` remains working. There are type tests for it, so feel -// free to tinker. -type ReactFormTypeErrorTypes = - unknown extends TSubmitReturn - ? any - : GetFormErrorTypes - /** * Derives the React form API type represented by a reusable options object. * @@ -59,7 +48,7 @@ export type ReactFormType = > ? ReactFormApi< TFormData, - ReactFormTypeErrorTypes, + GetSharedFormErrorTypes, TComponents extends AnyReactFormComponentMap ? TComponents : DefaultReactFormComponentMap diff --git a/packages/react-form/tests/public/form-options/SharedSubmitErrors.test-d.tsx b/packages/react-form/tests/public/form-options/SharedSubmitErrors.test-d.tsx new file mode 100644 index 0000000000..2b292ad639 --- /dev/null +++ b/packages/react-form/tests/public/form-options/SharedSubmitErrors.test-d.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { expectTypeOf } from 'vitest' +import { formOptions, useForm } from '../../../src' +import type { ReactFormType, ValidationIssue } from '../../../src' + +const sharedOptions = formOptions({ + defaultValues: { name: '' }, +}) + +type SharedForm = ReactFormType + +function SharedErrors({ form }: { form: SharedForm }) { + expectTypeOf(form.state.errors).toEqualTypeOf>() + + return ( + state.errors}> + {(errors) => { + expectTypeOf(errors).toEqualTypeOf>() + return {errors[0]?.message} + }} + + ) +} + +export function SharedSubmitErrors() { + const form = useForm({ + ...sharedOptions, + defaultValues: { name: 'Loaded name' }, + onSubmit: ({ createValidationError }) => + createValidationError({ + form: { message: '', code: 'query' as const }, + fields: { name: { message: '', code: 'name' as const } }, + }), + }) + + expectTypeOf(form).toExtend() + expectTypeOf(form.state.errors).toEqualTypeOf< + Array<{ message: string; code: 'query' }> + >() + + return +} diff --git a/packages/react-form/tests/public/form-options/ValidatorOutputs.test-d.tsx b/packages/react-form/tests/public/form-options/ValidatorOutputs.test-d.tsx new file mode 100644 index 0000000000..4b01bded64 --- /dev/null +++ b/packages/react-form/tests/public/form-options/ValidatorOutputs.test-d.tsx @@ -0,0 +1,117 @@ +import z from 'zod' +import { expectTypeOf } from 'vitest' +import React from 'react' +import { createFormHook, formOptions, useForm } from '../../../src' +import type { + ReactFormType, + StandardSchemaV1Issue, + ValidationIssue, +} from '../../../src' + +const formOpts = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: (ctx) => { + return ctx.createOutput({ foo: 'Custom data' }) + }, + }, + { + run: z + .object({ + name: z.string(), + }) + .transform((obj) => ({ bar: obj.name })), + }, + { + run: (ctx) => { + const result = z + .object({ + name: z.string(), + }) + .transform((obj) => obj.name.length) + .safeParse(ctx.value) + + if (!result.success) return ctx.parseIssues(result.error.issues) + + return ctx.createOutput(result.data) + }, + }, + ], +}) + +type MyForm = ReactFormType + +function Child(_props: { form: MyForm }) { + return <> +} + +function Component() { + const form = useForm({ + ...formOpts, + onSubmit: (ctx) => { + expectTypeOf(ctx.value).toEqualTypeOf<{ name: string }>() + expectTypeOf(ctx.validatorOutputs).toEqualTypeOf< + [{ foo: string }, { bar: string }, number] + >() + + return ctx.createValidationError({ message: '' }) + }, + }) + + return +} + +// App-Form + +const SubmitButton = () => null + +const { appFormOptions, useAppForm } = createFormHook({ + fieldComponents: {}, + formComponents: { SubmitButton }, +}) + +const appFormOpts = appFormOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: ({ value, createOutput }) => { + if (Math.random() < 0.5) return 'Error' + return createOutput({ foo: value.name }) + }, + }, + { + run: z.object({ name: z.string() }).transform(({ name }) => ({ + bar: name, + })), + }, + ], +}) + +type MyAppForm = ReactFormType + +function AppChild(_props: { form: MyAppForm }) { + return <> +} + +function AppComponent() { + const form = useAppForm({ + ...appFormOpts, + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< + [{ foo: string } | undefined, { bar: string }] + >() + }, + }) + + expectTypeOf(form.state.errors).toEqualTypeOf< + Array + >() + + return ( + + + + + ) +} diff --git a/packages/react-form/tests/public/shared-form/WithValidators.tsx b/packages/react-form/tests/public/shared-form/WithValidators.tsx new file mode 100644 index 0000000000..d50df9ee1c --- /dev/null +++ b/packages/react-form/tests/public/shared-form/WithValidators.tsx @@ -0,0 +1,81 @@ +import { formOptions } from '@tanstack/form-core' +import z from 'zod' +import { expectTypeOf } from 'vitest' +import React from 'react' +import { useForm } from '../../../src' +import type { ReactFormType, StandardSchemaV1Issue } from '../../../src' + +const defaultValues = { + name: '', +} +const schema = z.object({ + name: z.string(), +}) + +const opts = formOptions({ + defaultValues, + validators: [ + { + run: schema, + }, + ], +}) + +type Form = ReactFormType + +const optsStrict = formOptions.strictSchema(schema, { + defaultValues: { name: '' }, + validators: [ + { + run: schema, + }, + ], +}) + +const optsLoose = formOptions.strictSchema(schema, { + defaultValues, + validators: [ + { + run: schema, + }, + ], +}) + +function Child1(_props: { form: Form }) { + return <> +} + +function Component() { + const form = useForm({ + ...opts, + onSubmit: (ctx) => { + expectTypeOf(ctx.validatorOutputs).toEqualTypeOf<[{ name: string }]>() + }, + }) + + const formLoose = useForm({ + ...optsLoose, + onSubmit: (ctx) => { + expectTypeOf(ctx.validatorOutputs).toEqualTypeOf<[{ name: string }]>() + }, + }) + + const formStrict = useForm({ + ...optsStrict, + onSubmit: (ctx) => { + expectTypeOf(ctx.validatorOutputs).toEqualTypeOf<[{ name: string }]>() + }, + }) + + expectTypeOf(form.state.errors).toEqualTypeOf() + expectTypeOf(formLoose.state.errors).toEqualTypeOf() + expectTypeOf(formStrict.state.errors).toEqualTypeOf() + + return ( + <> + + + + + ) +} diff --git a/packages/react-form/tests/public/shared-form/WithoutValidators.tsx b/packages/react-form/tests/public/shared-form/WithoutValidators.tsx new file mode 100644 index 0000000000..03e29c789f --- /dev/null +++ b/packages/react-form/tests/public/shared-form/WithoutValidators.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { expectTypeOf } from 'vitest' +import { formOptions, useForm } from '../../../src' +import type { ReactFormType, ValidationIssue } from '../../../src' + +const formOpts = formOptions({ + defaultValues: { name: '' }, +}) + +type MyForm = ReactFormType + +function Child({ form }: { form: MyForm }) { + expectTypeOf(form.state.errors).toEqualTypeOf>() + + return ( + state.errors}> + {(errors) => { + expectTypeOf(errors).toEqualTypeOf>() + return {errors[0]?.message} + }} + + ) +} + +function Component() { + const form = useForm({ + ...formOpts, + defaultValues: { name: 'Loaded name' }, + onSubmit: ({ createValidationError }) => + createValidationError({ + form: { message: '', code: 'query' as const }, + fields: { name: { message: '', code: 'name' as const } }, + }), + }) + + expectTypeOf(form.state.errors).toEqualTypeOf< + Array<{ message: string; code: 'query' }> + >() + + return +} diff --git a/packages/solid-form/src/formType.public.ts b/packages/solid-form/src/formType.public.ts index ed5978633c..49cd95c10e 100644 --- a/packages/solid-form/src/formType.public.ts +++ b/packages/solid-form/src/formType.public.ts @@ -2,6 +2,7 @@ import type { AnyFormOptions, FormOptions, GetFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { AnySolidFormComponentMap, @@ -9,11 +10,6 @@ import type { } from './AppForm/componentMap.public' import type { SolidFormApi } from './formApiTypes.public' -type SolidFormTypeErrorTypes = - unknown extends TSubmitReturn - ? any - : GetFormErrorTypes - /** * Derives the Solid form API type represented by a reusable options object. * @@ -53,7 +49,7 @@ export type SolidFormType = > ? SolidFormApi< TFormData, - SolidFormTypeErrorTypes, + GetSharedFormErrorTypes, TComponents extends AnySolidFormComponentMap ? TComponents : DefaultSolidFormComponentMap diff --git a/packages/svelte-form/src/formType.public.ts b/packages/svelte-form/src/formType.public.ts index 064c1965c0..c1de153745 100644 --- a/packages/svelte-form/src/formType.public.ts +++ b/packages/svelte-form/src/formType.public.ts @@ -2,6 +2,7 @@ import type { AnyFormOptions, FormOptions, GetFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { AnySvelteFormComponentMap, @@ -9,11 +10,6 @@ import type { } from './AppForm/componentMap.public' import type { SvelteFormApi } from './formApiTypes.public' -type SvelteFormTypeErrorTypes = - unknown extends TSubmitReturn - ? any - : GetFormErrorTypes - export type SvelteFormType = TOptions extends FormOptions< infer TData, @@ -24,7 +20,7 @@ export type SvelteFormType = > ? SvelteFormApi< TData, - SvelteFormTypeErrorTypes, + GetSharedFormErrorTypes, TComponents extends AnySvelteFormComponentMap ? TComponents : DefaultSvelteFormComponentMap diff --git a/packages/vue-form/src/VueForm/formType.public.ts b/packages/vue-form/src/VueForm/formType.public.ts index 2974d3a8fb..536766a09b 100644 --- a/packages/vue-form/src/VueForm/formType.public.ts +++ b/packages/vue-form/src/VueForm/formType.public.ts @@ -1,7 +1,7 @@ import type { AnyFormOptions, FormOptions, - GetFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { AnyVueFormComponentMap, @@ -9,11 +9,6 @@ import type { } from '../AppForm/componentMap.public' import type { VueFormApi } from './formApiTypes.public' -type VueFormTypeErrorTypes = - unknown extends TSubmitReturn - ? any - : GetFormErrorTypes - /** * Derives the Vue form API type represented by a reusable options object. * @@ -47,7 +42,7 @@ export type VueFormType = > ? VueFormApi< TFormData, - VueFormTypeErrorTypes, + GetSharedFormErrorTypes, TComponents extends AnyVueFormComponentMap ? TComponents : DefaultVueFormComponentMap From dbfe603ac7f5c3be2b9c28811d7d4e572af60245 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:24:14 +0100 Subject: [PATCH 43/43] fix: adjust internal form options --- packages/form-core/src/FormApi/FormApi.lib.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index c88107d3ba..4d0a90bd35 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -88,6 +88,7 @@ import type { FormOptionsOnSubmitInvalidFn, } from './formSubmit.public' import type { FormValidator } from '../validation/formValidators.public' +import type { StandardSchemaV1 } from '../standardSchema.public' import type { ServerFormState } from './formServerState.public' import type { ClientSideEvents, @@ -158,15 +159,20 @@ export type InternalFormValidatorInstance = InternalValidatorInstance< AnyInternalFieldApi > +type InternalFormValidator = Omit< + FormValidator, + 'run' +> & { + run: StandardSchemaV1 | ((context: any) => any) +} + // Funky form options are cool and all, but just using `any` killed our type inference internally. // This is hacky, but it should do. type FormOptionsOfType = Omit< FormOptions, 'validators' | 'listeners' | 'onSubmit' | 'onSubmitInvalid' > & { - validators?: ReadonlyArray< - FormValidator, ValidatorTriggers | boolean> - > + validators?: ReadonlyArray>> listeners?: FormListeners, any> onSubmit?: FormOptionsOnSubmitFn, any, any> onSubmitInvalid?: FormOptionsOnSubmitInvalidFn, any>