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. 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/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/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/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/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/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/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/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/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 e5b8a0fead..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 @@ -25,8 +28,10 @@ export const formOpts = formOptions({ 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 7c75fd1b93..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 @@ -26,11 +29,17 @@ export const formOpts = formOptions({ 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 7c75fd1b93..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 @@ -26,11 +29,17 @@ export const formOpts = formOptions({ 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/react/ui-integration-shadcn/src/app/booking/shared-form.tsx b/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx index ea4bc1da35..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 @@ -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' 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/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/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'), 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/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..9f7a3df718 100644 --- a/packages/angular-form/src/form-type.ts +++ b/packages/angular-form/src/form-type.ts @@ -1,14 +1,10 @@ -import type { AnyFormOptions, FormOptions } from '@tanstack/form-core' import type { - AnyInternalFormApi, - InternalFormApi, -} from '@tanstack/form-core/internals' - -// 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 + AnyFormApi, + AnyFormOptions, + FormApi, + FormOptions, + GetSharedFormErrorTypes, +} from '@tanstack/form-core' /** * An Angular form API whose form data, validator, and submit types are erased. @@ -30,7 +26,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 +53,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/FieldApi/FieldApi.lib.ts b/packages/form-core/src/FieldApi/FieldApi.lib.ts index 55e6e41376..8d1ae20fdf 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() @@ -1145,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/FieldApi/FieldApi.public.ts b/packages/form-core/src/FieldApi/FieldApi.public.ts index eef81964f4..762c55fba1 100644 --- a/packages/form-core/src/FieldApi/FieldApi.public.ts +++ b/packages/form-core/src/FieldApi/FieldApi.public.ts @@ -2,14 +2,14 @@ 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 } from '../validation/state.public' +import type { ErrorVisibilityFn } from '../FormApi/errorVisibility.public' export interface BaseFieldMeta { isTouched: boolean @@ -125,12 +125,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 +229,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 +242,8 @@ export interface FieldApiOptions< TFieldData, TFieldName, TFieldValue, - ToFieldError, TGroupFieldError, TFormErrorTypes>, + NoInfer>, 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 c3b9a3e87a..4d0a90bd35 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,37 @@ 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, + 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 { StandardSchemaV1 } from '../standardSchema.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 { isDirty: Atom @@ -144,18 +152,32 @@ 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 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>> + listeners?: FormListeners, any> + onSubmit?: FormOptionsOnSubmitFn, any, any> + onSubmitInvalid?: FormOptionsOnSubmitInvalidFn, any> +} +type InternalFormOptions = FormOptionsOfType & { formId: string } @@ -200,13 +222,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 +235,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 +258,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 +317,7 @@ export class InternalFormApi< } constructor( - options: FormOptions, + options: FormOptionsOfType, defaultOptions?: DefaultOptions, ) { this._defaultOptions = defaultOptions @@ -336,7 +349,7 @@ export class InternalFormApi< owner: this, }) this._validatorInstances = reconcileValidatorInstances< - TFormValidators[number], + 'form', AnyInternalFormApi, AnyInternalFieldApi >({ @@ -420,7 +433,7 @@ export class InternalFormApi< this._notifyFormListener('reset', null) } - _update(options: FormOptions) { + _update(options: FormOptionsOfType) { const resolvedOptions = resolveDefaultOptions( options, this._defaultOptions?.form, @@ -449,7 +462,7 @@ export class InternalFormApi< }) this._validatorInstances = reconcileValidatorInstances< - TFormValidators[number], + any, AnyInternalFormApi, AnyInternalFieldApi >({ @@ -689,7 +702,11 @@ export class InternalFormApi< 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) } @@ -712,14 +729,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 +951,7 @@ export class InternalFormApi< } _processValidationResult( - result: PipelineResult>, + result: PipelineResult>, sourceEvent: string, ) { const validatorInstance = @@ -984,7 +1001,7 @@ export class InternalFormApi< } _processSubmitValidationResult( - result: FormValidateResult, + result: FormValidationResult, sourceEvent: string, ): void { const parsedResult = parseValidationResult(result) @@ -1042,7 +1059,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 +1081,7 @@ export class InternalFormApi< } async _runFormValidation( - signal: ValidationTrigger, + signal: ClientSideEvents, opts?: FieldApiOverrideOptions & { onResult?: boolean hasFailedBefore?: boolean @@ -1095,7 +1113,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 +1129,7 @@ export class InternalFormApi< } validate = async ( - signal: ValidationTrigger, + signal: ClientSideEvents, opts?: FieldApiOverrideOptions & { onResult?: boolean hasFailedBefore?: boolean @@ -1125,7 +1143,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 8091f516bb..24244ac3cc 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -1,418 +1,17 @@ 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 { SUBMIT_ERROR } from './handleSubmit.lib' - -/** - * A validation error wrapped for processing as an `onSubmit` failure. - * - * Create this value with the `createValidationError` or `parseIssues` 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 [SUBMIT_ERROR]: 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 - -export type CreateSubmitErrorMapFn = ( - initial?: Partial>, -) => ValidationErrorMap & - OnSubmitError> - -/** - * Converts Standard Schema issues into an `onSubmit` validation error. - * - * Issue paths are mapped to fields in the submitted value. Return the result - * from `onSubmit` to add the parsed errors to validation state. - * - * @param issues - The Standard Schema issues to convert. - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export type ParseSubmitIssuesFn = ( - issues: ReadonlyArray, -) => 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 - createErrorMap: CreateSubmitErrorMapFn - /** - * Parses Standard Schema issues into an error returnable from `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, parseIssues }) => { - * const result = zodSchema.safeParse(value) - * - * if (!result.success) { - * return parseIssues(result.error.issues) - * } - * }, - * } - * ``` - */ - parseIssues: ParseSubmitIssuesFn -} - -/** - * 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` - * 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 +import type { + FormValidationFailure, + ScopedErrorTypes, + ValidationIssue, +} from '../validation/state.public' +import type { DynamicValidatorTriggers } from '../events.public' -/** - * 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 `parseIssues` 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 -} +type FormError = + TFormError extends ScopedErrorTypes + ? TError + : never /** * A snapshot of current values, validation status, and submission metadata. @@ -425,7 +24,7 @@ export interface FormOptions< */ export interface FormState< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + out TFormError extends ScopedErrorTypes, > { /** * Current values after field edits, resets, or default-value updates. @@ -475,7 +74,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. * @@ -591,19 +190,20 @@ 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< - Array> + Array> > /** @@ -637,7 +237,8 @@ export type ResetFn = ( */ export interface FormApi< in out TFormData, - in out TFormErrorTypes extends FormErrorTypes, + // TODO in out? + TFormError extends ScopedErrorTypes, > extends FormApiFieldMethods, FormApiArrayMethods { /** @@ -646,9 +247,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 /** @@ -675,8 +276,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 @@ -685,15 +286,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/errorVisibility.public.ts b/packages/form-core/src/FormApi/errorVisibility.public.ts new file mode 100644 index 0000000000..e4573f7f5f --- /dev/null +++ b/packages/form-core/src/FormApi/errorVisibility.public.ts @@ -0,0 +1,36 @@ +import type { ScopedErrorTypes } 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 ErrorVisibilityFn = ( + 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..bf0c632629 --- /dev/null +++ b/packages/form-core/src/FormApi/formListeners.public.ts @@ -0,0 +1,34 @@ +import type { AnyFieldApi } from '../FieldApi/FieldApi.public' +import type { Listener } from '../listeners.public' +import type { ClientSideEvents, PreprocessEvents } from '../events.public' +import type { ScopedErrorTypes } from '../validation/state.public' +import type { FormApi } from './FormApi.public' + +export type FormListenerTriggers = ClientSideEvents | PreprocessEvents | 'reset' + +export interface FormListenerContext< + TFormData, + TFormError extends ScopedErrorTypes, +> { + formApi: FormApi + value: TFormData + triggerFieldApi?: AnyFieldApi +} + +export type FormListenerFn = ( + context: FormListenerContext, +) => void + +export type AnyFormListener = FormListener + +export interface FormListener< + TFormData, + TFormError extends ScopedErrorTypes, +> 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..bc4e775dc5 --- /dev/null +++ b/packages/form-core/src/FormApi/formOptions.public.ts @@ -0,0 +1,431 @@ +import type { GetFormErrorTypes } from '../validation/state.public' +import type { ErrorVisibilityFn } 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' +import type { + StandardSchemaInput, + StandardSchemaV1, +} from '../standardSchema.public' +import type { Editable, InferUnion } from '../utils.public' + +declare const formOptionsTypePayloadSymbol: unique symbol + +type TryGetValidatorOutputs = + TValidators extends ReadonlyArray + ? GetValidatorOutputs + : [] + +// 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. + * + * 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, + TSubmitReturn, + out TPayload, +> { + /** + * 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?: ErrorVisibilityFn> + /** + * 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< + NoInfer, + TValidatorConfig, + NoInfer + > + /** + * 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, + // 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 + * from the server on the client. + */ + serverState?: NoInfer> | null + /** + * 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>, + 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> + > +} + +/** + * 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 + >, +) => InferredFormOptions< + NoInfer>, + TValidators, + TSubmitReturn, + TPayload +> + +type LooseSchemaFormOptions< + TSchemaInput, + TFormData extends Editable, + TValidatorConfig extends unknown[], + TValidators, + TSubmitReturn, +> = Omit< + FormOptions< + InferUnion, + TValidatorConfig, + TValidators, + TSubmitReturn, + unknown + >, + 'defaultValues' +> & { + defaultValues: TFormData +} + +/** + * 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, + TSubmitReturn + >, +) => InferredFormOptions< + InferUnion>, + TValidators, + TSubmitReturn, + TPayload +> + +/** + * 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 + >, + ): InferredFormOptions + + /** + * 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 as never +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 = Omit< + FormOptions, + 'onSubmit' +> & { + onSubmit?: FormOptionsOnSubmitFn +} 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..083824c7e3 --- /dev/null +++ b/packages/form-core/src/FormApi/formServerState.public.ts @@ -0,0 +1,61 @@ +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<{ + validatorIndex: number + 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 new file mode 100644 index 0000000000..3a92e5bd3f --- /dev/null +++ b/packages/form-core/src/FormApi/formSubmit.public.ts @@ -0,0 +1,230 @@ +import type { StandardSchemaV1 } from '../standardSchema.public' +import type { PARSED_ISSUES } from '../validation/parseIssues.lib' +import type { + FormValidationFailure, + InferredErrorMapItem, + ParseIssuesFn, + ScopedErrorTypes, + ValidationErrorMap, + ValidationFailure, + ValidationFailureItem, + ValidationFieldMap, + ValidationOutput, +} from '../validation/state.public' +import type { FormApi } from './FormApi.public' +import type { ParseValidatorCondition } from '../validation/formValidators.public' +import type { Awaitable } from '../types.public' +import type { submitErrorSymbol } from './handleSubmit.lib' + +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 +} + +export type OnSubmitError = { + readonly [submitErrorSymbol]: T +} + +export type CreateValidationErrorFn = < + TError extends FormValidationFailure, +>( + error: TError, +) => OnSubmitError + +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?: Partial< + ValidationErrorMap + >, +) => CreatedSubmitErrorMap< + TFormData, + Array, + ValidationFailure +> + +/** + * 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[], +> { + /** 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[], + 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: FormSubmitInvalidContext, +) => Awaitable diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index 43beb1e991..b9f3c8db7c 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -4,63 +4,57 @@ import { isValidationErrorMap, parseValidationResult, } from '../validation' -import { parseStandardSchemaIssues } from '../standardSchema.lib' -import { createErrorMap } from '../validation.public' +import { createErrorMap } from '../validation/createErrorMap.lib' +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, - ParseSubmitIssuesFn, -} from './FormApi.public' +} from './formSubmit.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 +): value is OnSubmitError> { + return ( + typeof value === 'object' && value !== null && submitErrorSymbol in value + ) } const createValidationError: CreateValidationErrorFn = < - TError extends FormValidationError, + TError extends FormValidationFailure, >( error: TError, ): OnSubmitError => { return createSubmitError(error) } -function createSubmitError>( +function createSubmitError>( error: TError, ): OnSubmitError { - return { [SUBMIT_ERROR]: error } -} - -function createSubmitErrorMap( - initial?: Partial>, -): ReturnType> { - const map = createErrorMap(initial) - Object.defineProperty(map, SUBMIT_ERROR, { value: map }) - return map as ReturnType> + return { [submitErrorSymbol]: error } } -function createParseIssues( - value: TFormData, -): ParseSubmitIssuesFn { - return (issues) => { - return createSubmitError(parseStandardSchemaIssues(issues, value, 'form')) +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 @@ -73,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() @@ -142,7 +136,7 @@ export async function runSubmissionProcess( const finishInvalidSubmission = async ( value: TFormData, - ): Promise>> => { + ): Promise>> => { try { await form._options.onSubmitInvalid?.({ value, @@ -157,10 +151,10 @@ 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 ?? {} }] + return [{ ...error, form: self ?? [], fields: subfields ?? {} }] } return self ?? [] }) @@ -179,9 +173,9 @@ export async function runSubmissionProcess( validatorOutputs, value, createValidationError, - createErrorMap: createSubmitErrorMap, - parseIssues: createParseIssues(value), - }) + createErrorMap: withSubmitError(createErrorMap), + parseIssues: withSubmitError(parseIssuesFactory(value, 'field')), + } satisfies FormSubmitContext as any) if (hasResettedFormDuringSubmit()) { return [] @@ -189,7 +183,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 { diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index b8f563b005..1cf4ef4e13 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -18,8 +18,8 @@ import { deriveFromBaseFieldMeta, hasFieldMetaErrors, } from '../FieldApi/fieldState.lib' -import { parseStandardSchemaIssues } from '../standardSchema.lib' -import { createErrorMap } from '../validation.public' +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' @@ -41,20 +41,20 @@ import type { FormGroupState, } from './FormGroupApi.public' import type { - ConfigurableValidationTrigger, - FormErrorTypes, - FormErrors, + ClientValidationTrigger, FormGroupValidateResult, - FormGroupValidator, FormGroupValidators, ToFormGroupErrorTypes, - ValidationIssue, } from '../validation.public' import type { ReadonlyAtom } from '@tanstack/store' import type { InternalValidatorInstance, InternalValidatorInstances, } from '../ValidatorInstance.lib' +import type { + ScopedErrorTypes, + ValidationIssue, +} from '../validation/state.public' interface FormGroupValidationOutcome { errors: Array> @@ -69,7 +69,7 @@ export type AnyInternalFormGroupApi = InternalFormGroupApi< any > export type InternalGroupValidatorInstance = InternalValidatorInstance< - FormGroupValidator, + 'group', AnyInternalFormGroupApi, AnyInternalFieldApi > @@ -79,16 +79,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< @@ -96,11 +95,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 > @@ -133,7 +132,7 @@ export class InternalFormGroupApi< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, ) { this.form = options.form as never @@ -149,7 +148,7 @@ export class InternalFormGroupApi< ) this._groupField._setFormGroup(this) this._validatorInstances = reconcileValidatorInstances< - TGroupValidators[number], + 'group', AnyInternalFormGroupApi, AnyInternalFieldApi >({ @@ -182,7 +181,9 @@ export class InternalFormGroupApi< groupMetaMarkers, ) const groupErrorMeta = groupBaseMeta._validationSourceErrors - let groupErrors: FormErrors> + let groupErrors: Array< + ToFormGroupErrorTypes['selfError'] + > if ( prev && @@ -266,7 +267,7 @@ export class InternalFormGroupApi< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, ) => { const previousValidators = this._options.validators @@ -275,7 +276,7 @@ export class InternalFormGroupApi< this.form._defaultOptions?.formGroup, ) this._validatorInstances = reconcileValidatorInstances< - TGroupValidators[number], + 'group', AnyInternalFormGroupApi, AnyInternalFieldApi >({ @@ -482,7 +483,7 @@ export class InternalFormGroupApi< } async _runFieldValidations( - signal: ConfigurableValidationTrigger | 'submit', + signal: ClientValidationTrigger, ): Promise> { const fieldValidationPromises: Array< ReturnType @@ -544,7 +545,7 @@ export class InternalFormGroupApi< _clearEventErrors( field: AnyInternalFieldApi, sourceEvent: string, - event: ConfigurableValidationTrigger, + event: Exclude, ) { const groupField = this._groupField const validatorInstancesToClear: Array = [] @@ -645,7 +646,7 @@ export class InternalFormGroupApi< } _validate = async ( - signal: ConfigurableValidationTrigger | 'submit', + signal: ClientValidationTrigger, opts?: { triggerFieldApi?: AnyInternalFieldApi }, ): Promise> => { if (signal !== 'submit' && opts?.triggerFieldApi) { @@ -677,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, @@ -686,9 +686,11 @@ export class InternalFormGroupApi< signal: ctx.signal, value: this.value, createErrorMap, + parseIssues: parseIssuesFactory( + this.value, + instance.fieldErrorTarget, + ), createOutput, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, this.value, 'form'), }), onResult: (result) => { this._processValidationResult(result, signal) @@ -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/FormGroupApi/FormGroupApi.public.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts index 5ec9acd2d7..9bf6860131 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts @@ -1,14 +1,15 @@ 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, + FormGroupValidator, FormGroupValidators, ToFormGroupErrorTypes, ToFormGroupOutputs, } from '../validation.public' +import type { Awaitable } from '../types.public' /** * Context passed to a form group's `onSubmit` after group validation succeeds. @@ -28,20 +29,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 +82,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 +104,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,20 +112,48 @@ export type FormGroupSubmitFn< TGroupValue, ToFormGroupOutputs>, ToFormGroupErrorTypes>, - TFormErrorTypes + TFormError >, ) => 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, 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 + 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 * before submission finishes. @@ -144,7 +173,7 @@ export interface FormGroupOptions< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError > /** * Called when group validation fails or validation or submission throws. The @@ -162,24 +191,22 @@ export interface FormGroupOptions< * } * ``` */ - onSubmitInvalid?: ( - context: FormGroupSubmitInvalidContext< - TFormData, - TGroupName, - TGroupValue, - ToFormGroupErrorTypes>, - TFormErrorTypes - >, - ) => void | Promise + onSubmitInvalid?: FormGroupOnSubmitInvalidFn< + TFormData, + TGroupName, + TGroupValue, + NoInfer>, + TFormError + > } 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 +223,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..807bba0733 100644 --- a/packages/form-core/src/ValidatorInstance.lib.ts +++ b/packages/form-core/src/ValidatorInstance.lib.ts @@ -1,15 +1,18 @@ import { LiteDebouncer } from '@tanstack/pacer-lite' import { InternalValidationSourceInstance } from './ValidationSourceInstance.lib' -import type { StandardSchemaV1 } from './standardSchema.public' import type { - BaseValidator, - ValidatorFn, + FieldValidator, + FormGroupValidator, ValidatorScope, } from './validation.public' +import type { FormValidator } from './validation/formValidators.public' +import type { FieldErrorTarget } from './validation/state.public' -export type InternalValidatorDefinition = BaseValidator< - StandardSchemaV1 | ValidatorFn -> +type GetDefinition = { + form: FormValidator + group: FormGroupValidator + field: FieldValidator +}[TScope] export type ValidatorInstanceDebouncedFn = (...args: Array) => any @@ -19,25 +22,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: GetDefinition 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 +49,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 +75,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 +92,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 +101,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. */ @@ -127,15 +130,20 @@ 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, owner, scope, index, - }: InternalValidatorInstanceOptions) { + }: InternalValidatorInstanceOptions) { super({ owner, scope, index }) this.definition = definition + this.fieldErrorTarget = 'field' + if ('fieldErrorTarget' in definition && definition.fieldErrorTarget) { + this.fieldErrorTarget = definition.fieldErrorTarget + } } /** @@ -144,10 +152,14 @@ 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 + this.fieldErrorTarget = 'field' + if ('fieldErrorTarget' in definition && definition.fieldErrorTarget) { + this.fieldErrorTarget = definition.fieldErrorTarget + } this.revision++ } @@ -325,7 +337,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 +350,13 @@ export function reconcileValidatorInstances< scope, onBeforeDispose, }: ReconcileValidatorInstancesOptions< - TDefinition, + TScope, TOwner, TErrorTarget, TWatchedField, TOutput >): InternalValidatorInstances< - TDefinition, + TScope, TOwner, TErrorTarget, TWatchedField, @@ -375,7 +387,7 @@ export function reconcileValidatorInstances< instance.updateDefinition(definition) } else { nextInstances[index] = new InternalValidatorInstance< - TDefinition, + TScope, TOwner, TErrorTarget, TWatchedField, 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/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/index.ts b/packages/form-core/src/index.ts index 62df96180e..f99059c2ad 100644 --- a/packages/form-core/src/index.ts +++ b/packages/form-core/src/index.ts @@ -2,13 +2,20 @@ 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 './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/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index ea105f8b19..c09519e5eb 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -2,30 +2,21 @@ 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 { createErrorMap } from './validation/createErrorMap.lib' +import { parseIssuesFactory } from './validation/parseIssues.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 { @@ -33,7 +24,7 @@ function createInitialFormErrorMeta(): FormErrorMeta { } } -function resetFieldMetaForServerState(form: InternalFormApi) { +function resetFieldMetaForServerState(form: AnyInternalFormApi) { visitAllFormFields(form._fieldRootNode, (field) => { field._defaultValueCache = null @@ -49,8 +40,8 @@ function resetFieldMetaForServerState(form: InternalFormApi) { } function resetToServerState( - form: InternalFormApi, - serverState: ServerFormState, + form: InternalFormApi, + serverState: ServerFormState, defaultValues: TFormData, ): void { const values = serverState.values ?? defaultValues @@ -98,8 +89,8 @@ function resetToServerState( } export function applyServerState( - form: InternalFormApi, - serverState: ServerFormState | null, + form: InternalFormApi, + serverState: ServerFormState | null, defaultValues: TFormData, ): void { if (serverState === form._lastServerState) return @@ -116,12 +107,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) { @@ -141,7 +139,7 @@ export async function validateServerValues< try { const pipelineResult = await runValidatorPipeline< - ServerFormValidateResult + FormValidationResult >({ pipeline: validatorInstances ?? [], context: { @@ -150,17 +148,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, instance.fieldErrorTarget), createOutput, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, values, 'form'), }), - scope: 'form', }) if (pipelineResult.thrownError !== null) { 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..6e304c6d26 100644 --- a/packages/form-core/src/standardSchema.lib.ts +++ b/packages/form-core/src/standardSchema.lib.ts @@ -1,5 +1,7 @@ import { isNil } from './utils.lib' import { createOutput } from './validationOutput.lib' +import { PARSED_ISSUES, parseIssuesFactory } from './validation/parseIssues.lib' +import { validationSourceScopes } from './ValidationSourceInstance.lib' import type { StandardSchemaV1, StandardSchemaV1Issue, @@ -7,25 +9,38 @@ import type { import type { FieldValidatorFn, FormGroupValidatorFn, - FormValidatorFn, +} from './validation.public' +import type { FormValidatorFn } from './validation/formValidators.public' +import type { ParsedStandardSchemaIssues, + ValidationFieldMap, ValidationOutput, -} from './validation.public' +} 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( @@ -65,7 +80,7 @@ function getPathFromIssue( export function isStandardSchema( object: - | FormValidatorFn + | FormValidatorFn | FormGroupValidatorFn | FieldValidatorFn | StandardSchemaV1, @@ -77,50 +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 { - form: issues.slice(), - fields: prefixSchemaToErrors( - issues, - value, - ) as ParsedStandardSchemaIssues['fields'], - } -} 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/types.public.ts b/packages/form-core/src/types.public.ts index 0a3f0e913a..e684fccb9f 100644 --- a/packages/form-core/src/types.public.ts +++ b/packages/form-core/src/types.public.ts @@ -31,6 +31,12 @@ 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 diff --git a/packages/form-core/src/utils.lib.ts b/packages/form-core/src/utils.lib.ts index d1e0005cb0..c10f6e5ba7 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 | void | 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/utils.public.ts b/packages/form-core/src/utils.public.ts index 0955abaebf..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 @@ -9,7 +5,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,20 +14,6 @@ type EditableObject = { [K in keyof T]?: Editable } -export type InferUnion = TBase extends BuiltInType - ? TBase | TIncoming - : 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 - type InferUnionObject< in out TBase extends object, in out TIncoming extends object, @@ -45,334 +27,18 @@ type InferUnionObject< : 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 } +export type InferUnion = [TBase] extends [never] + ? TIncoming + : TBase extends BuiltInType + ? TBase | TIncoming + : 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 diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/validation.public.ts index b997866b1a..50dbf131bd 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/validation.public.ts @@ -1,13 +1,26 @@ 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 { StandardSchemaV1, StandardSchemaV1Issue, } from './standardSchema.public' -import type { OneOrMany } from './types.public' -import type { VALIDATION_OUTPUT } from './validationOutput.lib' +import type { OneOrMany, ReadonlyOneOrMany } from './types.public' +import type { + CreateErrorMapFn, + CreateOutputFn, + ParseIssuesFn, + 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. @@ -48,20 +61,13 @@ export interface Validator< /** * Whether this validator runs during a submission attempt, independently of `triggers`. * - * Pass a boolean directly, or pass a function that returns + * Pass a boolean to enable or disable it, or pass a function that returns * `true` to skip it for the current attempt. * * @default false */ 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. * @@ -74,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 @@ -122,13 +128,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 @@ -256,92 +258,13 @@ 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' -/** 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 +292,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 /** @@ -519,7 +439,11 @@ export interface ValidationTriggerConfig< * * @default true */ - when?: boolean | ValidationPredicateFn + when?: + | boolean + | (TTrigger extends 'mount' + ? never + : ValidationPredicateFn) } /** @@ -535,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. @@ -573,135 +499,12 @@ 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. */ export type ValidationError = OneOrMany /** One or more errors accepted from a validator, including string messages. */ -export type ValidationErrorInput = OneOrMany - -/** - * 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 a mutable map of form errors and errors for individual field paths. - * - * 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. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - */ -export type CreateErrorMapFn = - typeof createErrorMap - -/** - * 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>> -} - -/** - * 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`. - * - * 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 +export type ValidationErrorInput = ReadonlyOneOrMany /** * Values and helpers passed to a form validation function. @@ -710,7 +513,7 @@ export type CreateOutputFn = ( */ 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. */ @@ -719,10 +522,10 @@ 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 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 } @@ -743,10 +546,10 @@ 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 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 } @@ -763,21 +566,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. * @@ -788,77 +576,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 or a function that returns - * `true` to skip it for the current attempt. - * - * @default false - */ - skipOnSubmit?: 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 `skipOnSubmit` 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. * @@ -866,7 +583,7 @@ export type FormValidators = ReadonlyArray> */ 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. */ @@ -877,10 +594,10 @@ 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 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 } @@ -891,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. * @@ -945,7 +663,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. */ @@ -954,8 +672,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 +680,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. @@ -1032,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 @@ -1042,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 @@ -1083,24 +786,10 @@ type ExtractSubmitFieldError = unknown extends TSubmitReturn ? ValidationIssue : ParseSubmitFieldError -type ExtractFormError = - unknown extends TFormErrorTypes['formError'] - ? ValidationIssue - : TFormErrorTypes['formError'] - -type ExtractFormFieldError = - unknown extends TFormErrorTypes['fieldError'] +type ExtractFormFieldError = + unknown extends TFormError['subfieldError'] ? never - : TFormErrorTypes['fieldError'] - -/** - * 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 -> + : TFormError['subfieldError'] /** * A field's error array, with string messages normalized to issue objects. @@ -1111,37 +800,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 - : OneOrMany> - -/** - * 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 = @@ -1155,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 @@ -1194,7 +852,7 @@ type TryGetSubmitOutput = TryGetOutput extends infer TOutput ? TValidator extends { readonly skipOnSubmit: infer TSkipOnSubmit } ? [TSkipOnSubmit] extends [true] - ? undefined // User explicitly set skipOnSubmit: false -> guaranteed undefined + ? undefined // User explicitly set skipOnSubmit: true -> guaranteed undefined : TSkipOnSubmit extends (...args: Array) => boolean ? TOutput | undefined // Callback could dynamically be true or false -> union : true extends TSkipOnSubmit @@ -1203,17 +861,6 @@ type TryGetSubmitOutput = : 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 } @@ -1247,18 +894,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. * @@ -1290,30 +925,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. @@ -1335,60 +970,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 > - -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 `skipOnSubmit`. - * - * @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/createErrorMap.lib.ts b/packages/form-core/src/validation/createErrorMap.lib.ts new file mode 100644 index 0000000000..219250c16d --- /dev/null +++ b/packages/form-core/src/validation/createErrorMap.lib.ts @@ -0,0 +1,51 @@ +import { isReadonlyArray } from '../utils.lib' +import type { + CreateErrorMapFn, + ValidationErrorMap, + ValidationFailure, + ValidationFailureItem, +} from './state.public' + +function toArray(item: ValidationFailure | undefined): ValidationFailureItem[] { + if (isReadonlyArray(item)) return item.slice() + if (!item) return [] + return [item] +} + +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, + addFormError: (error) => { + mergeError(form, error) + }, + addFieldError: (fieldName, error) => { + fields[fieldName] = mergeError(fields[fieldName], error) + }, + } +}) 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 7a96dec81a..a3e535f6ef 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 b46ba54f3e..1f4a3f8fe3 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -1,11 +1,10 @@ -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' +import type { + AnyInternalFormApi, + InternalFormApi, +} from '../FormApi/FormApi.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' import type { FieldValidateResult, @@ -14,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 = { @@ -38,11 +37,11 @@ type ServerPipelineValidateContext = { } type FieldValidateContext = Omit< FieldValidatorContext, - 'value' | 'parseIssues' + 'value' > & { scope: 'field' } type FormGroupValidateContext = Omit< FormGroupValidatorContext, - 'value' | 'parseIssues' | 'createErrorMap' | 'createOutput' + 'value' | 'createErrorMap' | 'parseIssues' | 'createOutput' > & { scope: 'group' } export type FormInputContext = Omit type ServerFormInputContext = Omit @@ -60,9 +59,9 @@ export type ValidateContext = | FieldValidateContext | FormGroupValidateContext export type ValidateResult = - FormValidateResult | FormGroupValidateResult | FieldValidateResult + FormValidationResult | FormGroupValidateResult | FieldValidateResult type AnyPipelineValidator = - | FormValidator + | FormValidator | FormGroupValidator | FieldValidator | Validator @@ -100,18 +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 { - return validator.triggers.some(isServerTrigger) -} - /** * Creates the scope-specific context passed to predicate and debounce callbacks. * @@ -148,13 +135,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') @@ -227,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 @@ -236,6 +220,7 @@ export function shouldRunValidator( return !getEnabledState(skipOnSubmit, context) } + if (!validator.triggers) return false return validator.triggers.some((signal) => isValidationTriggerEnabled(signal, context), ) @@ -247,12 +232,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/formValidators.public.ts b/packages/form-core/src/validation/formValidators.public.ts new file mode 100644 index 0000000000..909f05e4ae --- /dev/null +++ b/packages/form-core/src/validation/formValidators.public.ts @@ -0,0 +1,590 @@ +import type { AnyFieldApi } from '../FieldApi/FieldApi.public' +import type { + ClientSideEvents, + DynamicValidatorTriggers, + PreprocessEvents, + ServerSideEvents, + StaticValidatorTriggers, + ValidatorEvents, + ValidatorTriggers, +} from '../events.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 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 + * 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 { + /** + * 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 + /** + * The field that triggered validation. `undefined` when no field triggered + * the run. + */ + fieldApi?: AnyFieldApi + /** + * The current value when the condition is evaluated. + */ + 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. + * + * @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 + +/** + * 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 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. + * - `'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' + */ + // 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 + * 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 +} + +/** + * 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[] + // 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. + * + * 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 + +interface FormValidatorConfig< + TFormData, + TValidatorConfig, + TAvailableTriggers, +> 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 +} + +/** + * 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 + FormValidatorConfig, + FormValidatorRunner< + TFormData, + 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, + // 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, + 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. + */ +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]: FormOptionsValidator< + TFormData, + TValidatorConfig, + TValidators, + K + > +} diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index b914809e5f..1662a45b43 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -1,37 +1,38 @@ import { createOutput } from '../validationOutput.lib' -import { createErrorMap } from '../validation.public' -import { - isStandardSchema, - parseStandardSchema, - parseStandardSchemaIssues, -} from '../standardSchema.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, - parseFieldIssues, + shouldRunValidator, } 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, + InputContext, ValidateResult, 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 { @@ -41,8 +42,11 @@ export interface FormMountValidatorPipelineResult { interface MountValidatorPipelineArgs { pipeline: ReadonlyArray - getContext: (signal: AbortSignal) => AnyValidatorContext - scope: 'field' | 'form' + context: InputContext + getContext: ( + signal: AbortSignal, + instance: AnyInternalValidatorInstance, + ) => AnyValidatorContext onResult?: (result: PipelineResult) => void } @@ -86,7 +90,6 @@ function processMountValidationExecutionResult( */ function executeMountValidator( getContext: MountValidatorPipelineArgs['getContext'], - scope: 'field' | 'form', validatorInstance: AnyInternalValidatorInstance, ): | ValidatorExecutionResult @@ -96,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() @@ -145,15 +152,13 @@ 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, >( pipeline: ReadonlyArray, getContext: MountValidatorPipelineArgs['getContext'], - scope: 'field' | 'form', startIndex: number, firstResult: PromiseLike>, hasFailedBefore: boolean, @@ -175,15 +180,10 @@ 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 - const result = executeMountValidator( - getContext, - scope, - validatorInstance, - ) + const result = executeMountValidator(getContext, validatorInstance) const executionResult = isPromiseLike(result) ? await result : result if ( @@ -206,21 +206,15 @@ 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, @@ -228,10 +222,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 { @@ -240,19 +233,14 @@ function runMountValidatorPipeline({ } } - const result = executeMountValidator( - getContext, - scope, - validatorInstance, - ) + const result = executeMountValidator(getContext, validatorInstance) if (isPromiseLike(result)) { return { didRun: true, asyncPromise: continueMountValidationFromAsyncResult( - pipeline, + mountPipeline, getContext, - scope, i, result, hasFailed, @@ -280,19 +268,21 @@ export function runFormMountValidatorPipeline({ formApi, onResult, }: FormMountValidatorPipelineArgs): FormMountValidatorPipelineResult { - return runMountValidatorPipeline>({ + return runMountValidatorPipeline>({ pipeline, - getContext: (signal) => ({ - event: 'mount' as never, + context: { scope: 'form', event: 'mount', formApi }, + getContext: (signal, instance) => ({ + event: 'mount', signal, formApi, value: formApi.state.values, createErrorMap, + parseIssues: parseIssuesFactory( + formApi.state.values, + instance.fieldErrorTarget, + ), createOutput, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, formApi.state.values, 'form'), }), - scope: 'form', onResult, }) } @@ -311,15 +301,19 @@ 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, value: fieldApi.value, - parseIssues: parseFieldIssues, }), - scope: 'field', onResult, }) } @@ -340,19 +334,26 @@ export function runGroupMountValidatorPipeline({ }: GroupMountValidatorPipelineArgs): FormMountValidatorPipelineResult { return runMountValidatorPipeline>({ pipeline, - getContext: (signal) => ({ - event: 'mount' as never, + context: { + scope: 'group', + event: 'mount', + formApi: groupApi.form, + groupApi, + }, + 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, + instance.fieldErrorTarget, + ), createOutput, - parseIssues: (issues) => - parseStandardSchemaIssues(issues, groupApi.value, 'form'), }), - scope: 'form', onResult, }) } 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..22e5fa452d --- /dev/null +++ b/packages/form-core/src/validation/parseIssues.lib.ts @@ -0,0 +1,24 @@ +import { parseIssuesToErrorMap } from '../standardSchema.lib' +import type { FieldErrorTarget, ParseIssuesFn } from './state.public' + +export const PARSED_ISSUES = Symbol('PARSED_ISSUES') + +export function parseIssuesFactory( + value: TFormData, + routeFieldErrorsTo: FieldErrorTarget, +): ParseIssuesFn { + 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 e90bc50387..4400e4af88 100644 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -1,7 +1,7 @@ import { createOutput } from '../validationOutput.lib' -import { createErrorMap } from '../validation.public' -import { parseStandardSchemaIssues } from '../standardSchema.lib' import { isNotNil } from '../utils.lib' +import { createErrorMap } from './createErrorMap.lib' +import { parseIssuesFactory } from './parseIssues.lib' import { isErrorResult } from './errors.lib' import { ABORTED_CALL, @@ -9,16 +9,11 @@ import { executeValidator, isFieldValidateContext, isServerValidateContext, - parseFieldIssues, runMaybeDebouncedValidator, shouldRunValidator, } 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, @@ -30,6 +25,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 @@ -47,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 } @@ -131,7 +130,6 @@ export async function runValidatorPipeline({ hasFailedBefore = false, getContext, onResult, - scope, validatorInstancesToRun = null, }: ValidatorPipelineArgs): Promise<{ results: Array> @@ -193,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< @@ -227,11 +228,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 } @@ -243,12 +244,12 @@ export function runFormValidatorPipeline({ onResult, hasFailedBefore, }: FormValidatorPipelineArgs): Promise { - return runValidatorPipeline>({ + return runValidatorPipeline>({ pipeline, context, onResult, hasFailedBefore, - getContext: (ctx) => { + getContext: (ctx, instance) => { if (isServerValidateContext(ctx)) { throw new Error('Server validation cannot run through client pipeline') } @@ -259,11 +260,11 @@ export function runFormValidatorPipeline({ signal: ctx.signal, value: ctx.formApi.state.values, createErrorMap, + parseIssues: parseIssuesFactory( + ctx.formApi.state.values, + instance.fieldErrorTarget, + ), createOutput, - parseIssues: ( - issues: Parameters[0], - ) => - parseStandardSchemaIssues(issues, ctx.formApi.state.values, 'form'), } if (!isFieldValidateContext(ctx)) { @@ -278,7 +279,6 @@ export function runFormValidatorPipeline({ fieldApi: ctx.fieldApi, } }, - scope: 'form', }) } @@ -337,10 +337,8 @@ export function runFieldValidatorPipeline({ signal: ctx.signal, fieldApi: context.fieldApi, value: context.fieldApi.value, - parseIssues: parseFieldIssues, } }, - scope: 'field', validatorInstancesToRun, }) } 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..cc8b623871 --- /dev/null +++ b/packages/form-core/src/validation/state.public.ts @@ -0,0 +1,237 @@ +import type { DeepKeys } from '../deep-keys.public' +import type { OnSubmitError } from '../FormApi/formSubmit.public' +import type { + StandardSchemaV1, + StandardSchemaV1Issue, +} from '../standardSchema.public' +import type { ReadonlyOneOrMany } from '../types.public' +import type { VALIDATION_OUTPUT } from '../validationOutput.lib' +import type { PARSED_ISSUES } from './parseIssues.lib' + +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. + * + * @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 +} + +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 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 + +export type FormValidationResult = + FormValidationFailure | ValidationSuccess + +export interface ParsedStandardSchemaIssues extends Required< + ValidationErrorMap< + TFormData, + Array, + Array + > +> { + readonly [PARSED_ISSUES]: true +} + +type ErrorMapItem = unknown extends TError + ? ValidationFailureItem + : TError extends ReadonlyArray + ? ErrorMapItem + : TError extends string + ? string + : TError + +export type InferredErrorMapItem = [ErrorMapItem] extends [ + never, +] + ? ValidationFailureItem + : ErrorMapItem + +export type CreateErrorMapFn = ( + initial?: Partial< + ValidationErrorMap + >, +) => ValidationErrorMapHelper + +export type ParseIssuesFn = ( + 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 GetValidatorsError = + // Unlike unknown (omitted validators), an explicit any keeps basic errors. + 0 extends 1 & TValidators + ? ValidationIssue + : TValidators extends ReadonlyArray + ? GetValidatorError + : never + +type GetSubmitError = 0 extends 1 & + TSubmitReturn + ? any + : unknown extends TSubmitReturn + ? ValidationIssue + : Awaited extends infer TResult + ? TResult extends OnSubmitError + ? GetResultError + : never + : never + +type GetSelfErrors = + | GetValidatorsError + | GetSubmitError + +type GetSubfieldErrors = + | GetValidatorsError + | GetSubmitError + +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/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 || diff --git a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts index a50f0e77d4..aa6402cc21 100644 --- a/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts +++ b/packages/form-core/tests/FieldApi/FieldApi.lib.spec.ts @@ -549,7 +549,11 @@ describe('InternalFieldApi - _update', () => { expect(form.state.canSubmit).toBe(true) }) - it('uses updated listener callbacks and triggers, retains omitted listeners, and disables listeners with empty triggers', () => { + 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: '' } }) @@ -590,6 +594,14 @@ describe('InternalFieldApi - _update', () => { field.handleBlur() expect(updatedListener).not.toHaveBeenCalled() + + field._update({ listeners: [] }) + field.handleBlur() + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('length of the listener array should not change'), + ) + expect(updatedListener).not.toHaveBeenCalled() }) }) 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 3dc9393ff7..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({ @@ -125,7 +297,6 @@ describe('InternalFormApi - _update', () => { defaultValues: { name: '' }, validators: [ { run: () => null, triggers: [] }, - // @ts-expect-error We're testing this on purpose { run: () => null, triggers: [] }, ], }) @@ -682,32 +853,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, - }, - ], - }) + 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, - }) + form.setFieldValue('name', 'Al', { + markAsTouched: false, + markAsDirty: false, + markAsBlurred: false, + }) - await expect - .poll(() => form.state.errors) - .toEqual([{ message: 'Name is too short' }]) - }, - ) + 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 } }) @@ -745,6 +958,39 @@ describe('InternalFormApi - setFieldValue', () => { }) 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('discards old watched fields and allows rebuilt fields to establish new listeners', () => { const discardedListener = vi.fn() const currentListener = vi.fn() 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..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,11 +2,9 @@ import { describe, expectTypeOf, it } from 'vitest' import { formOptions } from '../../src' import type { CreateSubmitErrorMapFn, - OnSubmitError, + GetFormErrorTypes, ParseSubmitIssuesFn, StandardSchemaV1Issue, - ToFormErrorTypes, - ValidationErrorMap, ValidationIssue, } from '../../src' @@ -23,10 +21,6 @@ describe('FormSubmitContext', () => { 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' } @@ -43,10 +37,10 @@ describe('FormSubmitContext', () => { type SubmitReturn = ReturnType> expectTypeOf< - ToFormErrorTypes<[], SubmitReturn>['formError'] + GetFormErrorTypes<[], SubmitReturn>['selfError'] >().toEqualTypeOf() expectTypeOf< - ToFormErrorTypes<[], SubmitReturn>['fieldError'] + GetFormErrorTypes<[], SubmitReturn>['subfieldError'] >().toEqualTypeOf() }) @@ -59,10 +53,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 new file mode 100644 index 0000000000..009bf62e87 --- /dev/null +++ b/packages/form-core/tests/FormApi/formOptions.public.test-d.ts @@ -0,0 +1,1780 @@ +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 { OnSubmitError } from '../../src/FormApi/formSubmit.public' +import type { ServerFormState } from '../../src/FormApi/formServerState.public' +import type { StandardSchemaV1Issue } from '../../src/standardSchema.public' +import type { + CreateErrorMapFn, + ParseIssuesFn, + ParsedStandardSchemaIssues, + ValidationIssue, +} 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({ + 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']), + 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(validatorOutputs).toEqualTypeOf< + [number, SchemaOutput, undefined] + >() + }, + }) + }) + + 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']), + 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(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 server state', () => { + it('accepts omitted, null, and initial server state without validators', () => { + const options = formOptions({ defaultValues: { name: '' } }) + expectTypeOf(options.serverState).toEqualTypeOf< + ServerFormState<{ name: string }> | 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(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), + }, + ], + 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' } | ValidationIssue> + >() + expectTypeOf(formApi.state.values).toEqualTypeOf<{ name: string }>() + }, + }, + ], + }) + }) +}) + +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.addFieldError('users[0].name', 'Required') + errors.addFormError({ message: 'Invalid' }) + // @ts-expect-error Error map fields must belong to the form values. + errors.addFieldError('missing', 'Invalid') + + const issues: ReadonlyArray = [ + { message: 'Required', path: ['users', 0, 'name'] }, + ] + + return parseIssues(issues) + }, + }, + ], + }) + }) + + 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' } }) + 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/FormApi/handleSubmit.lib.spec.ts b/packages/form-core/tests/FormApi/handleSubmit.lib.spec.ts index d534f7bd85..b9f376f1cf 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 }, @@ -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 1e3a4a5ab0..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' }), ]) @@ -480,14 +481,14 @@ describe('form - submission handling', () => { }) it('clears prior schema output when a dynamic submit predicate skips the validator', async () => { - let shouldSkipSubmit = false + let shouldSkipOnSubmit = false const onSubmit = vi.fn() const form = new InternalFormApi({ defaultValues: { name: 'test' }, validators: [ { run: z.object({ name: z.string() }), - skipOnSubmit: () => shouldSkipSubmit, + skipOnSubmit: () => shouldSkipOnSubmit, triggers: [], }, ], @@ -499,7 +500,7 @@ describe('form - submission handling', () => { expect.objectContaining({ validatorOutputs: [{ name: 'test' }] }), ) - shouldSkipSubmit = true + shouldSkipOnSubmit = true await form.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( @@ -645,7 +646,7 @@ describe('form - submission handling', () => { ], }) await form.validate('change') - // skipOnSubmit: true 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() }) @@ -669,7 +670,7 @@ describe('form - submission handling', () => { expect(validatorFn).not.toHaveBeenCalled() }) - it('supports skipOnSubmit as a function', async () => { + it('evaluates skipOnSubmit against the current form values', async () => { const validatorFn = vi .fn() .mockImplementation(() => ({ message: 'Conditional submit error' })) @@ -687,7 +688,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 +740,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 +779,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 +802,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 2ca600b9a8..5a2850d207 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 @@ -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' }), ]) @@ -561,7 +559,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 +572,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 +642,7 @@ describe('form - validation', () => { name: 'Name is required', }, }), - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -644,14 +660,17 @@ describe('form - validation', () => { run: z.object({ name: z.string().min(1, 'Name is required'), }), - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) + const field = form._getOrCreateFieldApi({ + name: 'name', + }) + await vi.waitFor(() => { - expect(form.state.errors).toEqual([ + expect(field.errors).toEqual([ expect.objectContaining({ message: 'Name is required' }), ]) }) @@ -663,8 +682,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 +712,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 +749,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 +775,7 @@ describe('form - validation', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: validator, }, ], @@ -783,8 +795,7 @@ describe('form - validation', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: () => { throw error }, @@ -808,8 +819,7 @@ describe('form - validation', () => { defaultValues: { name: '' }, validators: [ { - runOnMount: true, - triggers: [], + triggers: ['mount'], run: () => Promise.reject(error), }, ], @@ -842,8 +852,7 @@ describe('form - validation', () => { validators: [ { run: schema, - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -867,13 +876,11 @@ describe('form - validation', () => { validators: [ { run: skippedValidator, - runOnMount: false, - triggers: [], + triggers: [{ trigger: 'mount', when: false }], }, { run: mountValidator, - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -885,7 +892,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 +899,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 +914,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 +931,12 @@ describe('form - validation', () => { validators: [ { run: () => 'Mount error', - runOnMount: true, - triggers: [], + triggers: ['mount'], }, { bailIfInvalid: true, run: secondValidatorFn, - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -954,8 +956,7 @@ describe('form - validation', () => { name: 'Mount field error', }, }), - runOnMount: true, - triggers: [], + triggers: ['mount'], }, ], }) @@ -1145,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 5b7b0a5267..68cf3823c7 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 shouldSkipSubmit = false + 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() }), - skipOnSubmit: () => shouldSkipSubmit, + skipOnSubmit: () => shouldSkipOnSubmit, triggers: [], }, ], @@ -912,7 +910,7 @@ describe('FormGroupApi', () => { expect.objectContaining({ validatorOutputs: [{ name: 'Tony' }] }), ) - shouldSkipSubmit = true + shouldSkipOnSubmit = true await group.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( @@ -2045,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/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 2a8c348da3..e56627d12a 100644 --- a/packages/form-core/tests/serverValidate.spec.ts +++ b/packages/form-core/tests/serverValidate.spec.ts @@ -6,18 +6,14 @@ import { installDevtoolsBridge } from '../src/devtoolsBridge.lib' import { validateServerValues } from '../src/internals' import { formOptions, initialServerFormState } 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) { diff --git a/packages/form-core/tests/serverValidate.test-d.ts b/packages/form-core/tests/serverValidate.test-d.ts index 8fca505c90..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 - }, + triggers: [], skipOnSubmit: ({ formApi }) => { - expectTypeOf(formApi.state.values).toEqualTypeOf<{ - name: string - }>() + expectTypeOf(formApi.state.values).toEqualTypeOf() return false }, - triggerDebounceMs: ({ formApi }) => { - expectTypeOf(formApi.state.values).toEqualTypeOf<{ - name: string - }>() - return 0 - }, - triggers: [], - }, - { - run: () => { - return undefined + 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..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).toEqual({ - 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).toEqual({ - 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 24c09cd0ac..20f63456c4 100644 --- a/packages/form-core/tests/validation-output.test-d.ts +++ b/packages/form-core/tests/validation-output.test-d.ts @@ -1,16 +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, - FormValidators, - ToFormErrorTypes, + FormOptionsValidators, + FormValidator, + GetFormErrorTypes, + GetValidatorOutputs, ToFormGroupErrorTypes, ToFormGroupOutputs, - ToFormOutputs, ValidationErrorMap, ValidationIssue, ValidationOutput, @@ -18,10 +20,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>( @@ -35,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< @@ -63,7 +70,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 +91,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 +133,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,7 +148,9 @@ describe('validator output types', () => { { run: ({ createOutput, - }): ValidationOutput | ValidationErrorMap => + }): + | ValidationOutput + | ValidationErrorMap => createOutput(1), triggers: [], }, @@ -192,25 +197,25 @@ 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), @@ -234,12 +239,16 @@ describe('validator output types', () => { }, ]) - 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 +266,8 @@ describe('validator output types', () => { readonly [number] >() expectTypeOf>().toEqualTypeOf<{ - readonly formError: ValidationIssue - readonly fieldError: ValidationIssue + readonly selfError: ValidationIssue + readonly subfieldError: ValidationIssue }>() }) @@ -281,9 +290,7 @@ describe('validator output types', () => { 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 +301,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 c9f11e95e3..f3767a6be5 100644 --- a/packages/form-core/tests/validation-output.test.ts +++ b/packages/form-core/tests/validation-output.test.ts @@ -119,36 +119,34 @@ describe('validator outputs', () => { }) it('clears earlier outputs when a later submission skips or succeeds without output', async () => { - let shouldSkip = false + 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) : [], - skipOnSubmit: () => shouldSkip, - 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'] }), ) - shouldSkip = true + shouldSkipOnSubmit = true await form.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( expect.objectContaining({ validatorOutputs: [undefined] }), ) - shouldSkip = false + shouldSkipOnSubmit = false await form.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( expect.objectContaining({ validatorOutputs: ['Tony'] }), @@ -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, - skipOnSubmit: true, - 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, - skipOnSubmit: () => !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] }), diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts index 4212867cce..bd3460603f 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>, ) { @@ -125,7 +127,7 @@ describe('runFormValidatorPipeline', () => { const run = vi.fn(({ triggerFieldApi, value, parseIssues }) => { expect(triggerFieldApi).toBe(field) expect(value).toBe(formApi.state.values) - expect(parseIssues(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.parseIssues(issues)).toEqual({ + expect(fieldContext.parseIssues(issues)).toMatchObject({ form: issues, fields: { 'users[0].name': issues }, }) @@ -182,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: [], @@ -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'] } } }, ]) }) @@ -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: [], @@ -294,38 +294,38 @@ 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, + run: runsWithFalseCallback, skipOnSubmit: () => false, triggers: [], }, { - run: trueBoolean, + run: runsWithFalse, skipOnSubmit: false, triggers: [], }, { - run: falseBoolean, + run: skipsWithTrue, skipOnSubmit: true, triggers: [], }, { - run: falseCallback, + run: skipsWithTrueCallback, skipOnSubmit: () => true, triggers: [], }, @@ -333,20 +333,20 @@ describe('runFormValidatorPipeline', () => { 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 }] >() }, }) @@ -1282,7 +1282,7 @@ describe('runFormValidatorPipeline', () => { ]) }) - it('should not run zod schema validators with skipOnSubmit enabled', async () => { + it('does not run zod schema validators with skipOnSubmit: true', async () => { const formApi = getForm({ name: '' }) const schema = z.object({ 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 7adec17de0..aaf7a62b0f 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -6,7 +6,6 @@ import { createValidator, createValidators, formOptions, - createErrorMap as importCreateErrorMap, } from '../src' import type { AnyFieldApi, @@ -15,23 +14,21 @@ import type { FieldErrors, FieldValidators, FormApi, - FormErrorTypes, - FormErrors, FormGroupValidators, FormOptions, + FormOptionsValidators, FormState, FormSubmitInvalidContext, - FormValidators, + GetFormErrorTypes, + GetValidatorOutputs, OnSubmitError, ParseSubmitIssuesFn, ReusableErrorVisibilityState, + ScopedErrorTypes, StandardSchemaV1Issue, ToFieldError, - ToFormErrorTypes, ToFormGroupErrorTypes, ToFormGroupOutputs, - ToFormOutputs, - ValidationErrorMap, ValidationIssue, } from '../src' @@ -43,9 +40,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< @@ -64,20 +64,20 @@ const emptyFormValidators = defineFormValidators([]) const emptyFieldValidators = defineFieldValidators([]) type TestFormErrors< - TFormValidators extends FormValidators, + TFormValidators extends ReadonlyArray, TSubmitReturn, -> = FormErrors> +> = Array['selfError']> type TestFieldErrors< TFieldValidators extends FieldValidators, TGroupValidators extends FormGroupValidators, - TFormValidators extends FormValidators, + TFormValidators extends ReadonlyArray, TSubmitReturn, > = FieldErrors< ToFieldError< TFieldValidators, - ToFormGroupErrorTypes['fieldError'], - ToFormErrorTypes + ToFormGroupErrorTypes['subfieldError'], + GetFormErrorTypes > > @@ -119,9 +119,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 }]>() }, }) @@ -163,7 +161,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] >() }, }) @@ -186,7 +184,7 @@ describe('formOptions', () => { }, ], onSubmit: ({ validatorOutputs }) => { - expectTypeOf(validatorOutputs).toEqualTypeOf() + expectTypeOf(validatorOutputs).toEqualTypeOf<[undefined]>() }, }) @@ -377,6 +375,7 @@ describe('ErrorVisibility', () => { it('types callback scoped and pre-visibility field state', () => { const options: FormOptions< TestFormData, + [], typeof emptyFormValidators, never, unknown @@ -384,10 +383,7 @@ describe('ErrorVisibility', () => { defaultValues: { name: '' }, errorVisibility: ({ state, fieldState }) => { expectTypeOf(state).toEqualTypeOf< - FormState< - TestFormData, - ToFormErrorTypes - > + FormState >() expectTypeOf(fieldState).toEqualTypeOf() expectTypeOf(fieldState.value).toEqualTypeOf() @@ -407,7 +403,7 @@ describe('ErrorVisibility', () => { typeof emptyFieldValidators, [], TestFormData, - ToFormErrorTypes + GetFormErrorTypes > = { name: 'name', errorVisibility: ({ fieldState }) => fieldState.value === '', @@ -473,6 +469,7 @@ describe('ErrorVisibility', () => { it('rejects removed string presets', () => { const formOpts: FormOptions< TestFormData, + [], typeof emptyFormValidators, never, unknown @@ -488,7 +485,7 @@ describe('ErrorVisibility', () => { typeof emptyFieldValidators, [], TestFormData, - ToFormErrorTypes + GetFormErrorTypes > = { name: 'name', // @ts-expect-error String visibility presets were replaced by callbacks. @@ -501,42 +498,13 @@ 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([ { 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' @@ -557,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' @@ -578,7 +546,7 @@ describe('createErrorMap', () => { run: ({ createErrorMap }) => { const errors = createErrorMap() - errors.fields.name = 'Name is required' + errors.addFieldError('name', 'Name is required') return errors }, @@ -601,8 +569,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 >() @@ -669,47 +636,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([ { @@ -767,7 +732,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({ @@ -786,8 +751,8 @@ describe('FormErrors', () => { void form }) - it('should fall back omitted submit errors to ValidationIssue submit meta', () => { - expectTypeOf>().not.toBeAny() + it('does not infer submit errors from an unknown return', () => { + expectTypeOf>().not.toBeAny() expectTypeOf>().toEqualTypeOf< Array >() @@ -798,19 +763,18 @@ describe('FormErrors', () => { triggers: [ { trigger: 'change', - when: ({ formApi }) => formApi.state.submissionAttempts > 0, + when: ({ formApi }: { formApi: FormApi }) => + formApi.state.submissionAttempts > 0, }, ], 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 }]>() }, }) @@ -823,7 +787,8 @@ describe('FormErrors', () => { triggers: [ { trigger: 'change', - when: ({ formApi }) => formApi.state.submissionAttempts > 0, + when: ({ formApi }: { formApi: FormApi }) => + formApi.state.submissionAttempts > 0, }, ], skipOnSubmit: false, @@ -832,7 +797,8 @@ describe('FormErrors', () => { triggers: [ { trigger: 'change', - when: ({ formApi }) => formApi.state.submissionAttempts > 0, + when: ({ formApi }: { formApi: FormApi }) => + formApi.state.submissionAttempts > 0, }, ], skipOnSubmit: false, @@ -867,12 +833,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] >() }, }) @@ -1139,11 +1105,13 @@ describe('FormErrors', () => { }) it('should ignore broad validators and preserve the submit fallback', () => { - expectTypeOf>().toEqualTypeOf>() - - expectTypeOf>().toEqualTypeOf< + expectTypeOf>().toEqualTypeOf< Array >() + + expectTypeOf>().toEqualTypeOf>() + expectTypeOf['selfError']>().toBeAny() + expectTypeOf['subfieldError']>().toBeAny() }) }) @@ -1316,7 +1284,7 @@ describe('FieldErrors', () => { it('should infer field errors from parsed Standard Schema issues', () => { const fieldValidators = defineFieldValidators([ { - run: ({ parseIssues }) => parseIssues([{ message: '' }]), + run: (): ReadonlyArray => [{ message: '' }], triggers: [], }, ]) @@ -1518,12 +1486,12 @@ 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 >() }) @@ -1558,15 +1526,15 @@ describe('validator type transforms', () => { }, ]) - type Outputs = ToFormOutputs + type Outputs = GetValidatorOutputs expectTypeOf().toEqualTypeOf< - readonly [ - // Assume skipOnSubmit 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 }, @@ -1600,12 +1568,12 @@ describe('validator type transforms', () => { { run: z.object({ name: z.string() }), triggers: [], - skipOnSubmit: () => true, + skipOnSubmit: () => false, }, { run: z.object({ name: z.string() }), triggers: [], - skipOnSubmit: () => false, + skipOnSubmit: () => true, }, { run: z.object({ name: z.string() }), @@ -1620,13 +1588,13 @@ describe('validator type transforms', () => { ]) type Output = { name: string } - type Outputs = ToFormOutputs - type Expected = readonly [ + 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, @@ -1634,7 +1602,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, ] @@ -1649,7 +1617,7 @@ describe('validator type transforms', () => { }, ]) - type Error = ToFieldError> + type Error = ToFieldError> expectTypeOf().toEqualTypeOf<{ message: string @@ -1659,7 +1627,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 = { @@ -1668,7 +1636,7 @@ describe('validator type transforms', () => { } expectTypeOf< - ToFieldError> + ToFieldError> >().toEqualTypeOf() }) @@ -1699,7 +1667,7 @@ describe('validator type transforms', () => { type Outputs = ToFormGroupOutputs expectTypeOf().toEqualTypeOf< - FormErrorTypes< + ScopedErrorTypes< StandardSchemaV1Issue | { message: string; fromGroup: true }, StandardSchemaV1Issue | { message: string; fromGroupField: true } > @@ -1711,15 +1679,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-core/tests/validation/createErrorMap.lib.spec.ts b/packages/form-core/tests/validation/createErrorMap.lib.spec.ts new file mode 100644 index 0000000000..15592cba61 --- /dev/null +++ b/packages/form-core/tests/validation/createErrorMap.lib.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { createErrorMap } from '../../src/validation/createErrorMap.lib' + +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 map = createErrorMap({ form }) + + expect(map).toMatchObject({ form: expected, fields: {} }) + }) + + it('normalizes initial field validation errors', () => { + const map = createErrorMap({ + fields: { name: 'Required', age: ['Too young', 'Must be an adult'] }, + }) + + expect(map).toMatchObject({ + form: [], + fields: { name: ['Required'], age: ['Too young', 'Must be an adult'] }, + }) + }) + + it('allows editing errors without changing the supplied readonly containers', () => { + const issue = { message: 'Invalid' } + // 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 + expect(nameErrors).toBeInstanceOf(Array) + map.addFieldError('name', { message: 'Too short' }) + map.addFormError({ message: 'Check the form' }) + map.addFieldError('age', { message: 'Too young' }) + + expect(map).toMatchObject({ + form: [issue, { message: 'Check the form' }], + fields: { + name: [issue, { message: 'Too short' }], + age: [issue, { message: 'Too young' }], + }, + }) + expect(initial).toEqual({ + form: [issue], + fields: { name: [issue], age: issue }, + }) + }) +}) 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..5a865ff351 --- /dev/null +++ b/packages/form-core/tests/validation/parseIssues.lib.spec.ts @@ -0,0 +1,93 @@ +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: {}, + }) + // 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)( + '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: {}, + }) + // Apparently, toMatchObject() with {} is valid. + // Probably a good reason for that, but we need this + // to strictly match. + expect(result.fields).toEqual({}) + }, + ) +}) 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..600c934040 --- /dev/null +++ b/packages/form-core/tests/validation/state.public.test-d.ts @@ -0,0 +1,72 @@ +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.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', () => { + 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.addFormError('Required') + omitted.addFormError({ message: 'Invalid' }) + emptyFields.addFieldError('name', { message: 'Invalid' }) + }) + + 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.addFieldError('name', 'Required') + // @ts-expect-error Mutation also checks field paths. + map.addFieldError('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([]) + }) +}) 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 5d827294fe..6772ebf4a1 100644 --- a/packages/form-devtools/tests/fieldDebugCases.test.ts +++ b/packages/form-devtools/tests/fieldDebugCases.test.ts @@ -45,7 +45,7 @@ describe('field debug cases', () => { emptyTriggerValidator, { ...emptyTriggerValidator, - runOnMount: true, + triggers: ['mount'], skipOnSubmit: true, }, ] as never, @@ -78,7 +78,7 @@ describe('field debug cases', () => { validators: [ { ...emptyTriggerValidator, - runOnMount: true, + 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/lit-form/src/get-form-type.ts b/packages/lit-form/src/get-form-type.ts index 471c74ee89..b79b17689e 100644 --- a/packages/lit-form/src/get-form-type.ts +++ b/packages/lit-form/src/get-form-type.ts @@ -1,10 +1,11 @@ -import type { AnyFormOptions, FormOptions } from '@tanstack/form-core' +import type { + AnyFormOptions, + FormOptions, + GetFormErrorTypes, + GetSharedFormErrorTypes, +} from '@tanstack/form-core' import type { TanStackFormController } from './tanstack-form-controller.js' -type LitFormTypeSubmitReturn = unknown extends TSubmitReturn - ? any - : TSubmitReturn - /** * A Lit form controller whose form data, validator, and submit types are * erased. @@ -28,7 +29,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,23 +59,18 @@ export type AnyLitFormApi = TanStackFormController export type LitFormType = TOptions extends FormOptions< infer TFormData, - infer TFormValidators, + any, + infer TValidators, infer TSubmitReturn, - unknown + any > - ? TanStackFormController< + ? // Keep option-only generics erased so component-level submit handlers remain + // assignable through update(), while preserving the derived public errors. + TanStackFormController< TFormData, - TFormValidators, - LitFormTypeSubmitReturn + any, + any, + any, + GetSharedFormErrorTypes > : 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/src/tanstack-form-controller.ts b/packages/lit-form/src/tanstack-form-controller.ts index fd6910b8a3..d536348d52 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,29 +201,24 @@ 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 - > { - return this.#form + get api(): FormApi { + return this.#form as never } /** @@ -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..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' @@ -21,7 +20,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() @@ -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/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/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/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/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/preact-form/src/PreactForm/formType.public.ts b/packages/preact-form/src/PreactForm/formType.public.ts index 76f67c04d5..45a6d1055e 100644 --- a/packages/preact-form/src/PreactForm/formType.public.ts +++ b/packages/preact-form/src/PreactForm/formType.public.ts @@ -1,8 +1,8 @@ import type { AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { AnyPreactFormComponentMap, @@ -10,19 +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< - TFormValidators extends FormValidators, - TSubmitReturn, -> = unknown extends TSubmitReturn - ? any - : ToFormErrorTypes - /** * Derives the Preact form API type represented by a reusable options object. * @@ -55,13 +42,14 @@ type PreactFormTypeErrorTypes< export type PreactFormType = TOptions extends FormOptions< infer TFormData, + any, infer TFormValidators, infer TSubmitReturn, infer TComponents > ? PreactFormApi< TFormData, - PreactFormTypeErrorTypes, + GetSharedFormErrorTypes, TComponents extends AnyPreactFormComponentMap ? TComponents : DefaultPreactFormComponentMap 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 de378876f6..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 @@ -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 - }) }) }) 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-nextjs/src/index.ts b/packages/react-form-nextjs/src/index.ts index 1ca5bf0958..0bfd751322 100644 --- a/packages/react-form-nextjs/src/index.ts +++ b/packages/react-form-nextjs/src/index.ts @@ -1,17 +1,9 @@ import { decode } from 'decode-formdata' import { validateServerValues } from '@tanstack/form-core/internals' -import type { - FormOptions, - FormValidators, - ServerValidateFrameworkPlugin, - ServerValidateResult, -} from '@tanstack/form-core' +import type { FormOptions, ServerValidateResult } from '@tanstack/form-core' import type { FormDataInfo } from 'decode-formdata' -export type NextServerValidate< - TFormData, - TFormValidators extends FormValidators, -> = ( +export type NextServerValidate = ( formData: FormData, ) => Promise> @@ -19,14 +11,6 @@ export interface NextServerValidateOptions { info?: FormDataInfo } -type NextCreateServerValidate = < - TFormData, - const TFormValidators extends FormValidators, - TSubmitReturn, ->( - options: FormOptions, -) => NextServerValidate - function decodeFormData( formData: FormData, info: FormDataInfo | undefined, @@ -34,28 +18,30 @@ function decodeFormData( return (info ? decode(formData, info) : decode(formData)) as TFormData } -export function next( - options: NextServerValidateOptions = {}, -): ServerValidateFrameworkPlugin { - return { - id: 'react-form-nextjs', - createServerValidate: < +export function next(options: NextServerValidateOptions = {}) { + const createServerValidate = < + TFormData, + const TValidatorConfig extends unknown[], + TValidators, + TSubmitReturn, + >( + formOptions: FormOptions< TFormData, - const TFormValidators extends FormValidators, + TValidatorConfig, + TValidators, TSubmitReturn, - >( - formOptions: FormOptions< - TFormData, - TFormValidators, - TSubmitReturn, - unknown - >, - ) => { - return async (formData) => { - const values = decodeFormData(formData, options.info) + unknown + >, + ): NextServerValidate => { + return async (formData: FormData) => { + const values = decodeFormData(formData, options.info) - return validateServerValues(formOptions, values) - } - }, + return validateServerValues(formOptions, values) + } + } + + return { + id: 'react-form-nextjs' as const, + createServerValidate, } } diff --git a/packages/react-form-start/package.json b/packages/react-form-start/package.json index 9deb900c17..7f6f326ce1 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 4fd142903f..e3e07cb46d 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 { createServerFn } from '@tanstack/react-start' import { deleteInternalTanStackCookie, getInternalTanStackCookie, 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 30574268ed..e67e7219d2 100644 --- a/packages/react-form-start/tests/start.spec.ts +++ b/packages/react-form-start/tests/start.spec.ts @@ -47,7 +47,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 @@ -80,7 +80,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 }, }, diff --git a/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts b/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts index 0f01c4d09e..5ba59da1f7 100644 --- a/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts +++ b/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts @@ -6,7 +6,7 @@ import { createFieldWithContext, createFormGroupWithContext, } from './Components.lib' -import type { DefaultOptions, FormOptions } from '@tanstack/form-core' +import type { AnyFormOptions, DefaultOptions } from '@tanstack/form-core' import type { AppFormComponent } from './ReactAppFormApi.public' import type { ReactComponentTree } from './componentMap.public' @@ -17,7 +17,7 @@ interface InternalReactAppFormApiInstance extends InternalReactFormApi { type InternalReactAppFormApiConstructor< TFormComponents extends ReactComponentTree, > = new ( - options: FormOptions, + options: AnyFormOptions, defaultOptions?: DefaultOptions, ) => InternalReactAppFormApiInstance & TFormComponents @@ -40,10 +40,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..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 } 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' @@ -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..9991322733 100644 --- a/packages/react-form/src/AppForm/createFormHookTypes.public.ts +++ b/packages/react-form/src/AppForm/createFormHookTypes.public.ts @@ -11,8 +11,7 @@ import type { DefaultFormOptions, FormOptions, FormOptionsApi, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' /** @@ -109,13 +108,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..3afa3a9bd1 100644 --- a/packages/react-form/src/ReactForm/Components.public.ts +++ b/packages/react-form/src/ReactForm/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' @@ -84,9 +84,9 @@ export type ReactFieldApi< TFieldValue, TFieldError, TFormData, - TFormErrorTypes extends FormErrorTypes, + TFormError extends ScopedErrorTypes, TFieldComponents extends ReactComponentTree, -> = FieldApi & +> = FieldApi & FieldComponentsMatchingType /** @@ -101,15 +101,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 +123,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 +132,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 +148,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 +166,7 @@ export type ReactFormFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): ReactNode @@ -178,7 +174,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 +192,7 @@ export type ReactFormArrayFieldComponent< TFieldValidators, never, TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): ReactNode @@ -204,25 +200,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 +231,9 @@ export interface ReactFormGroupFieldComponent< TFieldName, DeepValue, TFieldValidators, - TGroupErrorTypes['fieldError'], + TGroupError['subfieldError'], TFormData, - TFormErrorTypes, + TFormError, TFieldComponents >, ): ReactNode @@ -249,8 +242,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 +259,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 +273,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 +305,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 +313,7 @@ export interface ReactFormGroupProps< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes + TFormError >, 'form' > { @@ -328,7 +323,7 @@ export interface ReactFormGroupProps< TGroupName, TGroupValue, ToFormGroupErrorTypes>, - TFormErrorTypes, + TFormError, TFieldComponents >, ) => ReactNode @@ -336,7 +331,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 +344,7 @@ export interface ReactFormGroupComponent< TGroupName, TGroupValue, TGroupValidators, - TFormErrorTypes, + TFormError, TFieldComponents >, ): ReactNode @@ -357,25 +352,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..5b0d294e12 100644 --- a/packages/react-form/src/ReactForm/formApiTypes.public.ts +++ b/packages/react-form/src/ReactForm/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 { ReactTanStackFormComponents } from './Components.public' import type { AnyReactFormComponentMap, @@ -7,22 +7,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..8ba7f23050 100644 --- a/packages/react-form/src/ReactForm/formType.public.ts +++ b/packages/react-form/src/ReactForm/formType.public.ts @@ -1,8 +1,7 @@ import type { AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { AnyReactFormComponentMap, @@ -10,19 +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< - TFormValidators extends FormValidators, - TSubmitReturn, -> = unknown extends TSubmitReturn - ? any - : ToFormErrorTypes - /** * Derives the React form API type represented by a reusable options object. * @@ -55,13 +41,14 @@ type ReactFormTypeErrorTypes< export type ReactFormType = TOptions extends FormOptions< infer TFormData, + any, infer TFormValidators, infer TSubmitReturn, infer TComponents > ? ReactFormApi< TFormData, - ReactFormTypeErrorTypes, + GetSharedFormErrorTypes, TComponents extends AnyReactFormComponentMap ? TComponents : DefaultReactFormComponentMap 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..5d9c13bfac 100644 --- a/packages/react-form/src/ReactForm/useForm.public.ts +++ b/packages/react-form/src/ReactForm/useForm.public.ts @@ -1,8 +1,8 @@ import { initializeForm, useInternalForm } from './ReactFormApi.lib' import type { + AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, } from '@tanstack/form-core' import type { AnyReactFormComponentMap, @@ -12,17 +12,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/FormGroup.test-d.tsx b/packages/react-form/tests/FormGroup.test-d.tsx index 78a3548098..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 @@ -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.spec.tsx b/packages/react-form/tests/createFormHook.spec.tsx index 649f7c7b88..e75d97a2b5 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/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/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/react-form/tests/submit-return.test-d.tsx b/packages/react-form/tests/submit-return.test-d.tsx index 4d8ebf38a4..ddb41f5fec 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,79 @@ 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< + Array + >() + // @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< + Array + >() + // @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 +159,7 @@ describe('submit return', () => { } function ParentWithSubmitInComponent() { - const formWithSyncError = useAppForm({ + const formWithSyncError = useForm({ ...sharedOptionsWithoutSubmit, onSubmit: ({ createValidationError }) => { if (Math.random() > 0.5) { @@ -263,7 +173,7 @@ describe('submit return', () => { }, }) - const formWithAsyncError = useAppForm({ + const formWithAsyncError = useForm({ ...sharedOptionsWithoutSubmit, onSubmit: ({ createValidationError }) => Promise.resolve( @@ -275,11 +185,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 +209,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 + }) }) }) }) 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/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..4fa8e35124 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['subfieldError'], 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['subfieldError'], 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..49cd95c10e 100644 --- a/packages/solid-form/src/formType.public.ts +++ b/packages/solid-form/src/formType.public.ts @@ -1,8 +1,8 @@ import type { AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { AnySolidFormComponentMap, @@ -10,13 +10,6 @@ import type { } from './AppForm/componentMap.public' import type { SolidFormApi } from './formApiTypes.public' -type SolidFormTypeErrorTypes< - TFormValidators extends FormValidators, - TSubmitReturn, -> = unknown extends TSubmitReturn - ? any - : ToFormErrorTypes - /** * Derives the Solid form API type represented by a reusable options object. * @@ -49,13 +42,14 @@ type SolidFormTypeErrorTypes< export type SolidFormType = TOptions extends FormOptions< infer TFormData, + any, infer TFormValidators, infer TSubmitReturn, infer TComponents > ? SolidFormApi< TFormData, - SolidFormTypeErrorTypes, + GetSharedFormErrorTypes, TComponents extends AnySolidFormComponentMap ? TComponents : DefaultSolidFormComponentMap 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() }, }, ], 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..765e5e6c3a 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['subfieldError'], 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['subfieldError'], 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..c1de153745 100644 --- a/packages/svelte-form/src/formType.public.ts +++ b/packages/svelte-form/src/formType.public.ts @@ -1,8 +1,8 @@ import type { AnyFormOptions, FormOptions, - FormValidators, - ToFormErrorTypes, + GetFormErrorTypes, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { AnySvelteFormComponentMap, @@ -10,23 +10,17 @@ import type { } from './AppForm/componentMap.public' import type { SvelteFormApi } from './formApiTypes.public' -type SvelteFormTypeErrorTypes< - TValidators extends FormValidators, - TSubmitReturn, -> = unknown extends TSubmitReturn - ? any - : ToFormErrorTypes - export type SvelteFormType = TOptions extends FormOptions< infer TData, + any, infer TValidators, infer TSubmitReturn, infer TComponents > ? SvelteFormApi< TData, - SvelteFormTypeErrorTypes, + GetSharedFormErrorTypes, TComponents extends AnySvelteFormComponentMap ? TComponents : DefaultSvelteFormComponentMap 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() }, }, ], 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..536766a09b 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, + GetSharedFormErrorTypes, } from '@tanstack/form-core' import type { AnyVueFormComponentMap, @@ -10,13 +9,6 @@ import type { } from '../AppForm/componentMap.public' import type { VueFormApi } from './formApiTypes.public' -type VueFormTypeErrorTypes< - TFormValidators extends FormValidators, - TSubmitReturn, -> = unknown extends TSubmitReturn - ? any - : ToFormErrorTypes - /** * Derives the Vue form API type represented by a reusable options object. * @@ -43,13 +35,14 @@ type VueFormTypeErrorTypes< export type VueFormType = TOptions extends FormOptions< infer TFormData, + any, infer TFormValidators, infer TSubmitReturn, infer TComponents > ? VueFormApi< TFormData, - VueFormTypeErrorTypes, + GetSharedFormErrorTypes, TComponents extends AnyVueFormComponentMap ? TComponents : DefaultVueFormComponentMap 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/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', }, ], 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() }, }, ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f5734f8e1..94fd383dd4 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: