From f1463943754c31e6fb6e0fe7e77ab9a5b36022de Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:27:19 +0200 Subject: [PATCH 1/5] feat: allow custom validator output --- .changeset/grumpy-seals-dig.md | 21 + _artifacts/domain_map.yaml | 18 +- _artifacts/skill_spec.md | 8 +- _artifacts/skill_tree.yaml | 2 +- .../next-server-actions-zod/src/app/action.ts | 2 +- .../next-server-actions/src/app/action.ts | 2 +- examples/react/nextjs/app/action.ts | 2 +- .../react/tanstack-start/src/utils/form.tsx | 2 +- .../src/app/booking/booking-form.tsx | 2 +- packages/angular-form/src/form-group.ts | 5 +- .../form-core/src/FormApi/FormApi.public.ts | 19 +- .../form-core/src/FormApi/handleSubmit.lib.ts | 5 +- .../src/FormGroupApi/FormGroupApi.lib.ts | 10 +- .../src/FormGroupApi/FormGroupApi.public.ts | 49 +- .../form-core/src/ValidatorInstance.lib.ts | 61 +- packages/form-core/src/ssr.lib.ts | 14 +- packages/form-core/src/ssr.public.ts | 13 +- packages/form-core/src/standardSchema.lib.ts | 12 +- packages/form-core/src/utils.public.ts | 12 +- packages/form-core/src/validation.public.ts | 690 ++++++++++++++++-- .../form-core/src/validation/errors.lib.ts | 8 +- .../form-core/src/validation/execution.lib.ts | 29 +- .../form-core/src/validation/mount.lib.ts | 35 +- .../form-core/src/validation/pipeline.lib.ts | 31 +- .../form-core/src/validationOutput.lib.ts | 31 + .../tests/FieldApi/Lifecycle.spec.ts | 10 +- .../form-core/tests/FormApi/lifecycle.spec.ts | 8 +- .../tests/FormApi/submission-handling.spec.ts | 8 +- .../tests/FormGroupApi/FormGroupApi.spec.ts | 16 +- .../form-core/tests/ValidatorInstance.spec.ts | 72 +- .../form-core/tests/serverValidate.spec.ts | 23 +- .../form-core/tests/serverValidate.test-d.ts | 2 +- .../form-core/tests/standardSchema.spec.ts | 16 +- .../tests/validation-output.test-d.ts | 289 ++++++++ .../form-core/tests/validation-output.test.ts | 340 +++++++++ .../tests/validation-pipeline.test.ts | 60 +- packages/form-core/tests/validation.test-d.ts | 42 +- .../preact-form/tests/FormGroup.test-d.tsx | 8 +- .../tests/submit-return.test-d.tsx | 11 +- .../choosing-form-options-modes/SKILL.md | 16 +- .../references/schema-option-modes.md | 4 +- .../skills/schema-driven-forms/SKILL.md | 12 +- .../schema-validation-and-submit-output.md | 2 +- .../skills/type-error-debugging/SKILL.md | 2 +- .../references/type-error-boundaries.md | 2 +- .../react-form/tests/FormGroup.test-d.tsx | 8 +- .../react-form/tests/submit-return.test-d.tsx | 11 +- 47 files changed, 1677 insertions(+), 368 deletions(-) create mode 100644 .changeset/grumpy-seals-dig.md create mode 100644 packages/form-core/src/validationOutput.lib.ts create mode 100644 packages/form-core/tests/validation-output.test-d.ts create mode 100644 packages/form-core/tests/validation-output.test.ts diff --git a/.changeset/grumpy-seals-dig.md b/.changeset/grumpy-seals-dig.md new file mode 100644 index 0000000000..2c2655ac35 --- /dev/null +++ b/.changeset/grumpy-seals-dig.md @@ -0,0 +1,21 @@ +--- +'@tanstack/angular-form': minor +'@tanstack/form-core': minor +'@tanstack/preact-form': minor +'@tanstack/react-form': minor +--- + +BREAKING: `schemaOutputs` no longer exists. It is superceded by `validatorOutputs`. This is only a name change, the schema's result is the same as before + +`validatorOutputs` now allows a callback validator to specify data that the submit function should know. Schemas do this +automatically, but now you can do it manually as well: +`run: ({ createOutput }) => createOutput('This is not a validation error and onSubmit can use this')` + +The current map is: + +| Validator | `onSubmit: ({ validatorOutputs }) => ...` | +| -------------------------- | ----------------------------------------- | +| Schema | Schema's output data | +| `createOutput(X)` | `X` | +| Default callback validator | `undefined` | +| Validation error | N/A, `onSubmit` doesn't run | diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml index 2736fcbd80..8f978bd158 100644 --- a/_artifacts/domain_map.yaml +++ b/_artifacts/domain_map.yaml @@ -44,7 +44,7 @@ maintainer_guidance: properties: - 'defaultValues must align with the schema input type and should produce type errors when they do not.' - 'Callbacks may not have useful inference unless a schema is present in the validator array.' - - 'onSubmit should read the transform output from schemaOutputs instead of raw value.' + - 'onSubmit should read the transform output from outputs instead of raw value.' loose_schema: summary: 'Schema-source ruleset mode with editable defaults.' use_when: @@ -54,7 +54,7 @@ maintainer_guidance: - 'The input is looser while the parsed submit output is still the schema output type.' - 'defaultValues must still align with the schema shape, but properties may also be null or undefined where editing requires it.' - 'Callbacks may not have useful inference unless a schema is present in the validator array.' - - 'onSubmit should read the transform output from schemaOutputs instead of raw value.' + - 'onSubmit should read the transform output from outputs instead of raw value.' schema_ecosystem: standard_schema: role: 'Most common integration shape.' @@ -247,7 +247,7 @@ domains: - '@tanstack/form-core' core_questions: - 'Is the schema a pipeline from input A to output B, or a ruleset for confirming B?' - - 'Should submit logic read raw value or schemaOutputs?' + - 'Should submit logic read raw value or outputs?' - 'Do defaults need to be nullable or maybe undefined while editing?' related_skills: - choosing-form-options-modes @@ -492,7 +492,7 @@ skill_candidates: - 'looseSchema uses the schema as a ruleset while allowing editable default values that include null or undefined where needed.' - 'looseSchema defaultValues must still align with the schema shape, but the input can be looser while output remains schema output.' - 'In schema modes, callbacks may not infer usefully unless a schema is present in the validator array.' - - 'In schema modes, onSubmit should read schemaOutputs instead of raw value.' + - 'In schema modes, onSubmit should read outputs instead of raw value.' - 'Use Standard Schema-capable libraries as heuristic examples only: Zod generally leans ruleset/firewall, Valibot generally leans pipeline, ArkType is mixed, and Effect Schema often leans pipeline/codec.' failure_modes: - name: expecting_option_helper_runtime_behavior @@ -527,13 +527,13 @@ skill_candidates: - packages/form-core/tests/validation.test-d.ts - name: loose_pipeline_schema_loses_transform_intent symptom: 'Submit logic treats raw form value as if it were the transformed schema output.' - correction: 'Use strictSchema for pipeline schemas and read the transformed value from schemaOutputs.' + correction: 'Use strictSchema for pipeline schemas and read the transformed value from outputs.' evidence: - packages/form-core/tests/validation.test.ts - packages/form-core/tests/validation.test-d.ts - name: on_submit_reads_value_in_schema_mode symptom: 'onSubmit reads value even though strictSchema or looseSchema is being used.' - correction: 'Read the relevant schemaOutputs entry, because both schema modes preserve raw form value separately from parsed output.' + correction: 'Read the relevant outputs entry, because both schema modes preserve raw form value separately from parsed output.' evidence: - packages/form-core/tests/validation.test.ts - packages/form-core/src/FormApi/FormApi.public.ts @@ -551,7 +551,7 @@ skill_candidates: purpose: 'Help users turn Standard Schema-compatible schemas into form validators, field errors, typed submit output, and realistic examples.' entrypoints: - 'validators' - - 'schemaOutputs' + - 'outputs' - 'parseIssues' - 'createValidationError' - 'Standard Schema validators' @@ -566,13 +566,13 @@ skill_candidates: teaches: - 'Use Standard Schema-compatible validators in form or field validators.' - 'Extract reusable validator trigger configs with createValidator or createValidators when the policy is complex enough to deserve a semantic name.' - - 'Read parsed schema output from onSubmit context.schemaOutputs.' + - 'Read parsed schema output from onSubmit context.outputs.' - 'Return validation errors as values through createValidationError or parseIssues; do not throw them.' - 'Let Standard Schema issue paths route field errors instead of hand-building brittle path maps.' failure_modes: - name: assuming_schema_output_replaces_value symptom: 'onSubmit reads value expecting transformed/coerced data.' - correction: 'Use schemaOutputs for parsed schema output; value remains form state.' + correction: 'Use outputs for parsed schema output; value remains form state.' evidence: - packages/form-core/src/FormApi/FormApi.public.ts - packages/form-core/tests/validation.test.ts diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md index 2cb546be21..1a39ded0f5 100644 --- a/_artifacts/skill_spec.md +++ b/_artifacts/skill_spec.md @@ -173,7 +173,7 @@ Core guidance: - Use `looseSchema` when the schema is a ruleset for confirming valid output, but editable defaults need nullable or undefined values, such as a date field that starts as `null`. - In `looseSchema`, default values must still align with the schema shape, but properties may also be `null` or `undefined` where editing requires it. - In both schema modes, callbacks may not have useful inference unless there is a schema in the validator array for the helper to infer from. -- In both schema modes, `onSubmit` should read the parsed transform output from `schemaOutputs` rather than raw `value`. +- In both schema modes, `onSubmit` should read the parsed transform output from `outputs` rather than raw `value`. - Treat pipeline-vs-ruleset as TanStack Form guidance, not a Standard Schema concept. Standard Schema only gives the shared validation/typing interface. - Explain the mental model as: is this schema a pipeline of constraints/transforms, or a firewall that only lets valid values through? - List current Standard Schema-capable libraries as examples with a general leaning, not as hard categories. @@ -190,7 +190,7 @@ Important failure modes: - Using `looseSchema` but providing default values that no longer match the schema shape at all. - Using schema modes without putting a schema in the validator array, then expecting callbacks to infer from that schema. - Using loose schema mode for a true pipeline and then reading raw `value` as if it were parsed output. -- Reading `value` in `onSubmit` for schema-mode forms instead of reading the corresponding `schemaOutputs` entry. +- Reading `value` in `onSubmit` for schema-mode forms instead of reading the corresponding `outputs` entry. - Treating all Zod `input` and `output` differences as a real pipeline decision. Source trail: @@ -214,7 +214,7 @@ Purpose: Help users take a Standard Schema-compatible schema and wire it into va Primary APIs: - `validators` -- `schemaOutputs` +- `outputs` - `parseIssues` - `createValidationError` @@ -222,7 +222,7 @@ Core guidance: - Configure schema validation through validators. - Extract reusable validator trigger configs with `createValidator` or `createValidators` when the behavior is complex enough to need a semantic name. -- Read parsed schema output from `schemaOutputs`; raw `value` remains form state. +- Read parsed schema output from `outputs`; raw `value` remains form state. - Route submit validation errors as returned values through `createValidationError` or `parseIssues`; do not throw them. - Let Standard Schema issue paths route nested and array field errors when possible. - Keep UI metadata explicit or schema-library-specific; Standard Schema does not standardize it. diff --git a/_artifacts/skill_tree.yaml b/_artifacts/skill_tree.yaml index 3f1a579829..5be3cbdd3c 100644 --- a/_artifacts/skill_tree.yaml +++ b/_artifacts/skill_tree.yaml @@ -80,7 +80,7 @@ skills: domain: schema-driven-forms path: packages/react-form/skills/schema-driven-forms/SKILL.md package: packages/react-form - description: 'Use when wiring Standard Schema-compatible validators, validators arrays, schemaOutputs, parseIssues, createValidationError, nested issue paths, and schema submit output into React forms.' + description: 'Use when wiring Standard Schema-compatible validators, validators arrays, outputs, parseIssues, createValidationError, nested issue paths, and schema submit output into React forms.' requires: - choosing-form-options-modes sources: 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 786c4885e9..b9870bd645 100644 --- a/examples/react/next-server-actions-zod/src/app/action.ts +++ b/examples/react/next-server-actions-zod/src/app/action.ts @@ -24,7 +24,7 @@ export default async function someAction(_prev: unknown, formData: FormData) { return result.serverState } - console.log('validatedData', result.values, result.schemaOutputs) + console.log('validatedData', result.values, result.validatorOutputs) // Persist the form data to the database // await sql` // INSERT INTO users (name, email, password) diff --git a/examples/react/next-server-actions/src/app/action.ts b/examples/react/next-server-actions/src/app/action.ts index 786c4885e9..b9870bd645 100644 --- a/examples/react/next-server-actions/src/app/action.ts +++ b/examples/react/next-server-actions/src/app/action.ts @@ -24,7 +24,7 @@ export default async function someAction(_prev: unknown, formData: FormData) { return result.serverState } - console.log('validatedData', result.values, result.schemaOutputs) + console.log('validatedData', result.values, result.validatorOutputs) // Persist the form data to the database // await sql` // INSERT INTO users (name, email, password) diff --git a/examples/react/nextjs/app/action.ts b/examples/react/nextjs/app/action.ts index d368254c9b..c7d05bd7df 100644 --- a/examples/react/nextjs/app/action.ts +++ b/examples/react/nextjs/app/action.ts @@ -24,7 +24,7 @@ export default async function someAction(_prev: unknown, formData: FormData) { return result.serverState } - console.log('validatedData', result.values, result.schemaOutputs) + console.log('validatedData', result.values, result.validatorOutputs) return initialServerFormState } diff --git a/examples/react/tanstack-start/src/utils/form.tsx b/examples/react/tanstack-start/src/utils/form.tsx index 1346a48ccf..e36bd04f2a 100644 --- a/examples/react/tanstack-start/src/utils/form.tsx +++ b/examples/react/tanstack-start/src/utils/form.tsx @@ -21,7 +21,7 @@ export const handleForm = createServerFn({ method: 'POST' }) console.info('validated form value', { values: result.values, - schemaOutputs: result.schemaOutputs, + validatorOutputs: result.validatorOutputs, }) throw redirect({ diff --git a/examples/react/ui-integration-shadcn/src/app/booking/booking-form.tsx b/examples/react/ui-integration-shadcn/src/app/booking/booking-form.tsx index eabfeb703d..af5be9b022 100644 --- a/examples/react/ui-integration-shadcn/src/app/booking/booking-form.tsx +++ b/examples/react/ui-integration-shadcn/src/app/booking/booking-form.tsx @@ -100,7 +100,7 @@ export function BookingForm() { useStepper(0, 6) const form = useAppForm({ ...bookingFormOptions, - onSubmit: async ({ schemaOutputs: [result] }) => { + onSubmit: async ({ validatorOutputs: [result] }) => { await new Promise((resolve) => setTimeout(resolve, 1000)) alert('Form was submitted! See logs for result') console.log(result) diff --git a/packages/angular-form/src/form-group.ts b/packages/angular-form/src/form-group.ts index f35df6be8d..46aad0f9f0 100644 --- a/packages/angular-form/src/form-group.ts +++ b/packages/angular-form/src/form-group.ts @@ -10,6 +10,7 @@ import type { DeepKeys, DeepValue, FormGroupOptions, + FormGroupSubmitFn, FormGroupValidators, FormValidators, ToFormErrorTypes, @@ -35,13 +36,13 @@ export class TanStackFormGroup< validators = input>() onSubmit = input< - FormGroupOptions< + FormGroupSubmitFn< TFormData, TGroupName, TGroupValue, TGroupValidators, ToFormErrorTypes - >['onSubmit'] + > >() onSubmitInvalid = input< diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index be78ba7b74..f6bb373629 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -8,7 +8,7 @@ import type { FormValidators, ParsedStandardSchemaIssues, ToFormErrorTypes, - ToFormSchemaOutputs, + ToFormOutputs, } from '../validation.public' import type { StandardSchemaV1Issue } from '../standardSchema.public' import type { FormListeners } from '../listeners.public' @@ -95,12 +95,12 @@ export type ParseSubmitIssuesFn = ( * ``` * * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TSchemaOutputs - 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 TSchemaOutputs, + out TOutputs, in out TFormErrorTypes extends FormErrorTypes, > { /** The form values for this submission. */ @@ -108,21 +108,22 @@ export interface FormSubmitContext< /** The form API handling this submission. */ formApi: FormApi /** - * The submit outputs produced by the form's schema validators, ordered by - * validator index. + * 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 ({ schemaOutputs }) => { - * const validatedUser = schemaOutputs[0] + * onSubmit: async ({ validatorOutputs }) => { + * const validatedUser = validatorOutputs[0] * await saveUser(validatedUser) * }, * } * ``` */ - schemaOutputs: TSchemaOutputs + validatorOutputs: TOutputs /** * Creates a validation error that can be returned from `onSubmit`. * @@ -188,7 +189,7 @@ export type FormSubmitFn< > = ( context: FormSubmitContext< TFormData, - ToFormSchemaOutputs, + ToFormOutputs, ToFormErrorTypes >, ) => TSubmitReturn diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index b35a39deb6..ba32d34bdb 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -159,14 +159,13 @@ export async function runSubmissionProcess( return finishInvalidSubmission(form.state.values) } - const schemaOutputs = - form._validatorInstances?.map((v) => v.schemaOutput) ?? [] + const validatorOutputs = form._validatorInstances?.map((v) => v.output) ?? [] const value = form.state.values try { const maybeError = await form._options.onSubmit?.({ formApi: form as never, - schemaOutputs, + validatorOutputs, value, createValidationError, parseIssues: createParseIssues(value), diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts index 584b26c02e..b8f563b005 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts @@ -1,4 +1,5 @@ import { batch, createAtom, shallow } from '@tanstack/store' +import { createOutput } from '../validationOutput.lib' import { concatenateFieldNames, getBy, setBy } from '../utils.lib' import { clearValidationSourceErrorsFromEvent, @@ -424,8 +425,8 @@ export class InternalFormGroupApi< result: { validatorInstance: InternalGroupValidatorInstance result: FormGroupValidateResult - schemaResult: any | null - hasSchemaResult?: boolean + output: any | null + hasOutput?: boolean }, sourceEvent: string, ) { @@ -685,6 +686,7 @@ export class InternalFormGroupApi< signal: ctx.signal, value: this.value, createErrorMap, + createOutput, parseIssues: (issues) => parseStandardSchemaIssues(issues, this.value, 'form'), }), @@ -751,9 +753,9 @@ export class InternalFormGroupApi< value, formApi: this.form, groupApi: this, - schemaOutputs: Array.from( + validatorOutputs: Array.from( this._validatorInstances ?? [], - (validatorInstance) => validatorInstance.schemaOutput, + (validatorInstance) => validatorInstance.output, ), } as never diff --git a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts index bb9a5cda24..5ec9acd2d7 100644 --- a/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts +++ b/packages/form-core/src/FormGroupApi/FormGroupApi.public.ts @@ -7,7 +7,7 @@ import type { FormGroupValidateResult, FormGroupValidators, ToFormGroupErrorTypes, - ToFormGroupSchemaOutputs, + ToFormGroupOutputs, } from '../validation.public' /** @@ -27,7 +27,7 @@ export interface FormGroupSubmitContext< in out TFormData, in out TGroupName, in out TGroupValue, - out TSchemaOutputs, + out TOutputs, in out TGroupErrorTypes extends FormErrorTypes, in out TFormErrorTypes extends FormErrorTypes, > { @@ -44,21 +44,22 @@ export interface FormGroupSubmitContext< TFormErrorTypes > /** - * The submit outputs produced by the group's schema validators, ordered by - * validator index. + * Schema outputs and `createOutput` payloads from this group submission, + * ordered by validator index. Skipped validators and successful returns + * without an output contribute `undefined`. * * @example * ```ts * { * // ... - * onSubmit: async ({ schemaOutputs }) => { - * const validatedGuestDetails = schemaOutputs[0] + * onSubmit: async ({ validatorOutputs }) => { + * const validatedGuestDetails = validatorOutputs[0] * setStep(step => step + 1) * }, * } * ``` */ - schemaOutputs: TSchemaOutputs + validatorOutputs: TOutputs } /** @@ -97,6 +98,23 @@ export interface FormGroupSubmitInvalidContext< > } +export type FormGroupSubmitFn< + in out TFormData, + in out TGroupName, + in out TGroupValue, + in out TGroupValidators extends FormGroupValidators, + in out TFormErrorTypes extends FormErrorTypes, +> = ( + context: FormGroupSubmitContext< + TFormData, + TGroupName, + TGroupValue, + ToFormGroupOutputs>, + ToFormGroupErrorTypes>, + TFormErrorTypes + >, +) => void | Promise + export interface FormGroupOptions< in out TFormData, in out TGroupName, @@ -121,16 +139,13 @@ export interface FormGroupOptions< * } * ``` */ - onSubmit?: ( - context: FormGroupSubmitContext< - TFormData, - TGroupName, - TGroupValue, - ToFormGroupSchemaOutputs>, - ToFormGroupErrorTypes>, - TFormErrorTypes - >, - ) => void | Promise + onSubmit?: FormGroupSubmitFn< + TFormData, + TGroupName, + TGroupValue, + TGroupValidators, + TFormErrorTypes + > /** * Called when group validation fails or validation or submission throws. The * callback is awaited before submission finishes. diff --git a/packages/form-core/src/ValidatorInstance.lib.ts b/packages/form-core/src/ValidatorInstance.lib.ts index 0a8e321ec3..a64b546b03 100644 --- a/packages/form-core/src/ValidatorInstance.lib.ts +++ b/packages/form-core/src/ValidatorInstance.lib.ts @@ -34,14 +34,14 @@ export type InternalValidatorInstances< TOwner, TErrorTarget = unknown, TWatchedField = unknown, - TSchemaOutput = unknown, + TOutput = unknown, > = Array< InternalValidatorInstance< TDefinition, TOwner, TErrorTarget, TWatchedField, - TSchemaOutput + TOutput > > | null @@ -50,7 +50,7 @@ export interface ReconcileValidatorInstancesOptions< TOwner, TErrorTarget = unknown, TWatchedField = unknown, - TSchemaOutput = unknown, + TOutput = unknown, > { /** The latest validator definitions installed on the owner. */ definitions: ReadonlyArray | null | undefined @@ -67,7 +67,7 @@ export interface ReconcileValidatorInstancesOptions< TOwner, TErrorTarget, TWatchedField, - TSchemaOutput + TOutput > /** The validation boundary that owns every reconciled instance. */ owner: TOwner @@ -80,7 +80,7 @@ export interface ReconcileValidatorInstancesOptions< TOwner, TErrorTarget, TWatchedField, - TSchemaOutput + TOutput >, ) => void } @@ -93,7 +93,7 @@ export class InternalValidatorInstance< TOwner, TErrorTarget = unknown, TWatchedField = unknown, - TSchemaOutput = unknown, + TOutput = unknown, TDebouncedFn extends ValidatorInstanceDebouncedFn = ValidatorInstanceDebouncedFn, > extends InternalValidationSourceInstance { @@ -104,15 +104,15 @@ export class InternalValidatorInstance< /** The lazily created debouncer for this validator's pending execution. */ debouncer: LiteDebouncer | null = null /** - * The Standard Schema output assigned by the latest form or group submit. + * The schema output or branded payload assigned by the latest form or group submit. * * Submit pipelines cancel prior executions and clear this before evaluating * their validators. Field and non-submit pipelines do not assign it. Consult - * `hasSchemaOutput` because `undefined` can itself be a stored output. + * `hasOutput` because `undefined` can itself be a stored output. */ - schemaOutput: TSchemaOutput | undefined - /** Whether the current submit pipeline assigned `schemaOutput`. */ - hasSchemaOutput = false + output: TOutput | undefined + /** Whether the current submit pipeline assigned `output`. */ + hasOutput = false /** * Resolved fields referenced by this validator's `watchFields` definition. * @@ -203,29 +203,26 @@ export class InternalValidatorInstance< } /** - * Stores a submit pipeline's Standard Schema output when its result has one. + * Stores a submit pipeline's schema output or branded payload when present. * - * A result without a schema output leaves the current state unchanged. An + * A result without an output leaves the current state unchanged. An * explicit `undefined` is still considered a stored output. The operation is * ignored after disposal. * * @param result - The accepted result produced by the validator pipeline. */ - setSchemaOutput(result: { - schemaResult: TSchemaOutput | null - hasSchemaResult: boolean - }): void { - if (this.disposed || !result.hasSchemaResult) return - - this.schemaOutput = result.schemaResult as TSchemaOutput - this.hasSchemaOutput = true + setOutput(result: { output: TOutput | null; hasOutput: boolean }): void { + if (this.disposed || !result.hasOutput) return + + this.output = result.output as TOutput + this.hasOutput = true } - /** Clears the stored schema output and its presence marker. */ - clearSchemaOutput(): void { + /** Clears the stored output and its presence marker. */ + clearOutput(): void { if (this.disposed) return - this._clearSchemaOutput() + this._clearOutput() } /** @@ -294,7 +291,7 @@ export class InternalValidatorInstance< /** Releases validator-specific runtime state during reset or disposal. */ protected override _resetRuntime(): void { this._cancelExecution() - this._clearSchemaOutput() + this._clearOutput() } /** Releases validator-only collections and mount state during disposal. */ @@ -313,9 +310,9 @@ export class InternalValidatorInstance< } /** Clears the schema-output value and presence marker as one operation. */ - private _clearSchemaOutput(): void { - this.schemaOutput = undefined - this.hasSchemaOutput = false + private _clearOutput(): void { + this.output = undefined + this.hasOutput = false } } @@ -332,7 +329,7 @@ export function reconcileValidatorInstances< TOwner, TErrorTarget = unknown, TWatchedField = unknown, - TSchemaOutput = unknown, + TOutput = unknown, >({ definitions, previousDefinitions, @@ -345,13 +342,13 @@ export function reconcileValidatorInstances< TOwner, TErrorTarget, TWatchedField, - TSchemaOutput + TOutput >): InternalValidatorInstances< TDefinition, TOwner, TErrorTarget, TWatchedField, - TSchemaOutput + TOutput > { if ( previousDefinitions !== undefined && @@ -382,7 +379,7 @@ export function reconcileValidatorInstances< TOwner, TErrorTarget, TWatchedField, - TSchemaOutput + TOutput >({ definition, owner, scope, index }) } }) diff --git a/packages/form-core/src/ssr.lib.ts b/packages/form-core/src/ssr.lib.ts index d401a8852d..ea105f8b19 100644 --- a/packages/form-core/src/ssr.lib.ts +++ b/packages/form-core/src/ssr.lib.ts @@ -1,4 +1,5 @@ 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' @@ -127,7 +128,7 @@ export async function validateServerValues< return { success: true, values, - schemaOutputs: [] as never, + validatorOutputs: [] as never, } } @@ -155,6 +156,7 @@ export async function validateServerValues< formApi: undefined, value: values, createErrorMap, + createOutput, parseIssues: (issues) => parseStandardSchemaIssues(issues, values, 'form'), }), @@ -165,20 +167,20 @@ export async function validateServerValues< throw pipelineResult.thrownError } - const schemaOutputs = + const validatorOutputs = validatorInstances?.map((instance) => { const result = pipelineResult.results.find( (r) => r.validatorInstance === instance, ) - return result?.hasSchemaResult ? result.schemaResult : undefined + return result?.hasOutput ? result.output : undefined }) ?? [] const validationResults = pipelineResult.results.map((result) => ({ validatorIndex: validatorInstances?.indexOf(result.validatorInstance) ?? -1, result: result.result, - schemaResult: result.schemaResult, - hasSchemaResult: result.hasSchemaResult, + output: result.output, + hasOutput: result.hasOutput, })) if (pipelineResult.hasErrors) { @@ -195,7 +197,7 @@ export async function validateServerValues< return { success: true, values, - schemaOutputs: schemaOutputs as never, + validatorOutputs: validatorOutputs as never, } } finally { validatorInstances?.forEach((instance) => instance.dispose()) diff --git a/packages/form-core/src/ssr.public.ts b/packages/form-core/src/ssr.public.ts index 4cfef40fa9..14f04d4cb2 100644 --- a/packages/form-core/src/ssr.public.ts +++ b/packages/form-core/src/ssr.public.ts @@ -2,8 +2,8 @@ import type { FormOptions } from './FormApi/FormApi.public' import type { FormValidateResultFromErrorTypes, FormValidators, - ServerFormStandardSchemaValidatorOutputs, ToServerFormErrorTypes, + ToServerFormOutputs, } from './validation.public' export const initialServerFormState: ServerFormState = { @@ -32,8 +32,8 @@ export function serverValidateHelper< interface ServerStateValidationResult { validatorIndex: number result: TResult - schemaResult: unknown | null - hasSchemaResult?: boolean + output: unknown | null + hasOutput?: boolean } interface ServerFormStateByResult { @@ -59,7 +59,12 @@ export interface ServerValidateSuccess< > { success: true values: TFormData - schemaOutputs: ServerFormStandardSchemaValidatorOutputs + /** + * 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< diff --git a/packages/form-core/src/standardSchema.lib.ts b/packages/form-core/src/standardSchema.lib.ts index e57bb2dc1d..3c066332fd 100644 --- a/packages/form-core/src/standardSchema.lib.ts +++ b/packages/form-core/src/standardSchema.lib.ts @@ -79,15 +79,15 @@ export function parseStandardSchema( scope: 'form' | 'field', ): Promise<{ result: FormValidateResult - schemaResult: TOutput | null - hasSchemaResult: boolean + output: TOutput | null + hasOutput: boolean }> { return Promise.resolve(schema['~standard'].validate(value)).then((result) => { if (!result.issues) { return { result: null, - schemaResult: result.value, - hasSchemaResult: true, + output: result.value, + hasOutput: true, } } @@ -99,8 +99,8 @@ export function parseStandardSchema( return { result: validationResult, - schemaResult: null, - hasSchemaResult: false, + output: null, + hasOutput: false, } }) } diff --git a/packages/form-core/src/utils.public.ts b/packages/form-core/src/utils.public.ts index 0d7ede1740..0955abaebf 100644 --- a/packages/form-core/src/utils.public.ts +++ b/packages/form-core/src/utils.public.ts @@ -83,7 +83,7 @@ type LooseSchemaFormOptions< * * 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 `schemaOutputs` entries + * Parsed results are available in the corresponding `validatorOutputs` entries * during submission. * * @example @@ -141,7 +141,7 @@ export type FormOptionsStrictSchemaFn = < * * 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 `schemaOutputs` entries + * Parsed results are available in the corresponding `validatorOutputs` entries * during submission. * * @example @@ -237,7 +237,7 @@ export interface FormOptionsApi { * * 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 `schemaOutputs` entry during submission. + * 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 @@ -266,7 +266,7 @@ export interface FormOptionsApi { * value.name.length === 0 ? 'Name is required' : undefined, * }, * ], - * onSubmit: ({ schemaOutputs }) => saveProfile(schemaOutputs[0]), + * onSubmit: ({ validatorOutputs }) => saveProfile(validatorOutputs[0]), * }) * ``` * @@ -287,7 +287,7 @@ export interface FormOptionsApi { * 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 `schemaOutputs` entry during submission. + * 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 @@ -317,7 +317,7 @@ export interface FormOptionsApi { * value.startDate === null ? 'Choose a date' : undefined, * }, * ], - * onSubmit: ({ schemaOutputs }) => saveBooking(schemaOutputs[0]), + * onSubmit: ({ validatorOutputs }) => saveBooking(validatorOutputs[0]), * }) * ``` * diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/validation.public.ts index 9145fc9373..d4651aa696 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/validation.public.ts @@ -7,20 +7,37 @@ import type { StandardSchemaV1Issue, } from './standardSchema.public' import type { OneOrMany } from './types.public' +import type { VALIDATION_OUTPUT } from './validationOutput.lib' +/** + * A validation function or Standard Schema with an optional rule to stop after earlier failures. + * + * @typeParam TValidator - Library-managed. Do not specify explicitly. + */ export interface BaseValidator< out TValidator extends StandardSchemaV1 | ValidatorFn, > { + /** The function or Standard Schema that validates the value in this validator's scope. */ run: TValidator /** - * If `true`, this validator and all subsequent validators will be skipped if any previous validator has failed. - * If `false`, validators run regardless of earlier validation results. + * If `true`, skips this and subsequent validators when earlier validation has failed. + * + * If `false`, earlier failures do not prevent this validator from running. * * @default false */ bailIfInvalid?: boolean } +/** + * Configures validation for a form, form group, or field. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValidator - Library-managed. Do not specify explicitly. + * @typeParam TContextValue - Library-managed. Do not specify explicitly. + * @typeParam TTrigger - Library-managed. Do not specify explicitly. + * @typeParam TScope - Library-managed. Do not specify explicitly. + */ export interface Validator< in out TFormData, out TValidator extends StandardSchemaV1 | ValidatorFn, @@ -29,33 +46,52 @@ export interface Validator< in out TScope extends ValidatorScope = ValidatorScope, > extends BaseValidator { /** - * TODO docs + * Whether this validator runs during a submission attempt, independently of `triggers`. * - * Whether this validator should be called during a submission attempt. + * Pass a boolean to enable or disable it, or pass a function that returns + * `true` to enable it for the current attempt. * * @default true */ runOnSubmit?: boolean | ValidationPredicateFn /** - * Whether this validator should be called once when the form is constructed. + * Whether this validator runs when the form is constructed or when its field + * or form group first mounts. * * @default false */ runOnMount?: boolean /** - * The debounce time in milliseconds for validation triggers (change, blur). - * Does not affect submit events, which always execute immediately. + * 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 validation runs immediately. * * @default 0 */ triggerDebounceMs?: number | ValidationDebounceFn + /** + * Change and blur events that can run this validator, optionally with conditions. + * + * An empty array disables change and blur validation. Configure submission + * separately with `runOnSubmit` and initial validation with `runOnMount`. + */ triggers: Array< ValidationTriggerOption > } +/** + * Reusable scheduling and failure-handling options for `createValidator` or `createValidators`. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TContextValue - Library-managed. Do not specify explicitly. + * @typeParam TTrigger - Library-managed. Do not specify explicitly. + * @typeParam TScope - Library-managed. Do not specify explicitly. + */ export type ValidatorOptions< TFormData, TContextValue, @@ -95,11 +131,8 @@ type InferFormDataFromValidator = : any /** - * The validator produced by applying a run function or Standard Schema to a - * reusable `createValidator` configuration. - * - * This type is inferred from `createValidator`; application code normally - * does not need to name it directly. + * A validator created by combining reusable options with a validation function + * or Standard Schema through `createValidator`. * * @typeParam TOptions - Library-managed. Do not specify explicitly. * @typeParam TRun - Library-managed. Do not specify explicitly. @@ -140,6 +173,25 @@ type ValidatorsFromOptionsAndRuns< > } +/** + * Creates a reusable configuration to apply to validation functions or Standard Schemas. + * + * @returns A function that combines `options` with its `run` argument, preserving + * the validator's inferred types. Its `TValidator` type argument is library-managed. + * Do not specify it explicitly. + * @typeParam TOptions - Library-managed. Do not specify explicitly. + * + * @example + * ```ts + * const afterSubmit = createValidator({ + * triggers: [{ + * trigger: 'change', + * when: ({ formApi }) => formApi.state.submissionAttempts > 0, + * }], + * }) + * const validators = [afterSubmit(schema)] + * ``` + */ export function createValidator< const TOptions extends ValidatorOptions, >( @@ -150,6 +202,26 @@ export function createValidator< return (run: ValidatorRun) => ({ ...options, run }) as never } +/** + * Creates a reusable configuration for an ordered set of validators. + * + * @param options - A nonempty tuple of configurations, one per validator. + * @returns A function that accepts one validation function or Standard Schema + * per configuration, in the same order. Its `TRuns` type argument is library-managed. + * Do not specify it explicitly. + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TContextValue - Library-managed. Do not specify explicitly. + * @typeParam TOptions - Library-managed. Do not specify explicitly. + * + * @example + * ```ts + * const withChecks = createValidators([ + * { triggers: ['change'] }, + * { triggers: ['blur'], bailIfInvalid: true }, + * ]) + * const validators = withChecks(schema, () => 'Additional check failed') + * ``` + */ export function createValidators< TFormData = any, TContextValue = TFormData, @@ -177,52 +249,93 @@ export function createValidators< > } +/** Client validation events: value changes, field blur, and submission attempts. */ export type ValidationTrigger = 'change' | 'blur' | 'submit' +/** Selects a form validator for execution by `serverValidate`. */ export type ServerValidationTrigger = 'server' +/** Events reported to client-side validation callbacks. */ export type ClientValidationTrigger = ValidationTrigger +/** Events configured through `triggers`. Submission is controlled by `runOnSubmit`. */ export type ConfigurableValidationTrigger = Exclude +/** Change and blur events accepted by shared validator configurations. */ export type ValidatorTrigger = ConfigurableValidationTrigger +/** The form, form group, or field whose value a validator checks. */ export type ValidatorScope = 'form' | 'group' | 'field' +/** Descendant field state available when deciding whether to show errors. */ export interface ErrorVisibilitySubfieldsMeta { + /** `true` when no descendant field is marked as dirty. */ isEveryPristine: boolean + /** `true` when at least one descendant field is marked as dirty. */ isSomeDirty: boolean + /** `true` when at least one descendant field is marked as touched. */ isSomeTouched: boolean + /** `true` while at least one descendant field is validating. */ isSomeValidating: boolean } +/** Field state flags available to an error visibility policy. */ export interface ErrorVisibilityFieldMeta { + /** `true` when this field or a descendant is marked as touched. */ isTouched: boolean + /** `true` when this field itself is marked as touched. */ isSelfTouched: boolean + /** `true` when this field or a descendant is marked as dirty. */ isDirty: boolean + /** `true` when this field itself is marked as dirty. */ isSelfDirty: boolean + /** `true` when neither this field nor its descendants are marked as dirty. */ isPristine: boolean + /** `true` when the current value equals the field's default value. */ isDefaultValue: boolean + /** `true` when this field is marked as blurred. */ isBlurred: boolean + /** `true` while this field or a descendant is validating. */ isValidating: boolean + /** `true` while this field itself is validating. */ isSelfValidating: boolean + /** State flags for this field's descendants. */ subfields: ErrorVisibilitySubfieldsMeta } +/** The field value and state flags available to an error visibility policy. */ export interface ErrorVisibilityFieldState { + /** The current value of the field whose errors the policy controls. */ value: any + /** State flags before the visibility policy is applied. */ meta: ErrorVisibilityFieldMeta } +/** + * Form and field state used to decide whether to show a field's errors. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. + */ export interface ErrorVisibilityContext< in out TFormData, in out TFormErrorTypes extends FormErrorTypes, > { + /** + * Current form state. For a field in a form group, flags and submission + * counters describe that group. `values` and `errors` describe the whole form. + */ state: FormApi['state'] + /** The field whose errors the policy controls. */ fieldState: ErrorVisibilityFieldState } /** - * Decides whether a field exposes its validation errors publicly. + * 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, scalar meta properties read from `state` are - * scoped to the containing group. `values` and `errors` remain - * form-wide. + * 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, @@ -230,23 +343,32 @@ export type ErrorVisibility< > = (context: ErrorVisibilityContext) => boolean /** - * The scoped state view available while declaring a reusable visibility policy. + * Form or group state available to a reusable error visibility policy. * - * `values` remains unknown because a reusable policy is not associated with a - * particular form shape until it is assigned to a form or field option. + * `values` is `unknown` because the policy can be used with different form shapes. */ export type ReusableErrorVisibilityState = Omit< FormApi['state'], 'values' > & { + /** Current form values, without assuming a particular form shape. */ values: unknown } +/** State available to a visibility policy shared across different form shapes. */ export interface ReusableErrorVisibilityContext { + /** Form state with group-scoped flags and counters for fields in a form group. */ state: ReusableErrorVisibilityState + /** The field whose errors the policy controls. */ fieldState: ErrorVisibilityFieldState } +/** + * An error visibility policy that accepts forms with different value and error types. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. + */ export type ReusableErrorVisibility = < TFormData, TFormErrorTypes extends FormErrorTypes, @@ -255,10 +377,13 @@ export type ReusableErrorVisibility = < ) => boolean /** - * Creates a reusable, form-agnostic error visibility policy. + * Creates an error visibility policy that can be shared across forms with different values. * * Use an inline `errorVisibility` callback instead when the policy needs * strongly typed access to the consuming form's `values`. + * + * @param visibility - Returns `true` to show the field's errors, or `false` to hide them. + * @returns The policy to assign to a form or field's `errorVisibility` option. */ export function createErrorVisibility( visibility: (context: ReusableErrorVisibilityContext) => boolean, @@ -271,30 +396,50 @@ interface BaseValidationPredicateContext< out TValue, out TScope extends ValidatorScope, > { + /** Identifies whether `value` belongs to a form, form group, or field. */ scope: TScope + /** The owning form, including its complete values and state. */ formApi: FormApi /** - * The field associated with this validation, if any. + * The field associated with the validation event, when available. * * For form and group validators, this is the field that triggered the * validation. For field validators, this is the field being validated. */ fieldApi?: AnyFieldApi + /** The current value in this validator's scope when the callback is evaluated. */ value: TValue } +/** + * Current form state available to validator conditions and delay callbacks. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ export interface FormValidationPredicateContext< in out TFormData, > extends BaseValidationPredicateContext { groupApi?: never } +/** + * Current form group state available to validator conditions and delay callbacks. + * + * @typeParam TGroupValue - Library-managed. Do not specify explicitly. + */ export interface FormGroupValidationPredicateContext< in out TGroupValue, > extends BaseValidationPredicateContext { + /** The form group whose value is being validated. */ groupApi: FormGroupApi } +/** + * Current field state available to validator conditions and delay callbacks. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFieldValue - Library-managed. Do not specify explicitly. + */ export interface FieldValidationPredicateContext< in out TFormData, out TFieldValue, @@ -304,6 +449,13 @@ export interface FieldValidationPredicateContext< fieldApi: AnyFieldApi } +/** + * Context for validator conditions and delay callbacks, narrowed by `scope`. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValue - Library-managed. Do not specify explicitly. + * @typeParam TScope - Library-managed. Do not specify explicitly. + */ export type ValidationPredicateContext< TFormData, TValue, @@ -316,28 +468,68 @@ export type ValidationPredicateContext< ? FieldValidationPredicateContext : never +/** + * Returns `true` to enable validation for the current event or submission attempt. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValue - Library-managed. Do not specify explicitly. + * @typeParam TScope - Library-managed. Do not specify explicitly. + */ export type ValidationPredicateFn< in out TFormData, in out TValue, in TScope extends ValidatorScope = ValidatorScope, > = (context: ValidationPredicateContext) => boolean +/** + * Chooses the delay in milliseconds before change or blur validation runs. + * + * @returns The delay for the current event. Zero or a negative value runs validation immediately. + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValue - Library-managed. Do not specify explicitly. + * @typeParam TScope - Library-managed. Do not specify explicitly. + */ export type ValidationDebounceFn< in out TFormData, in out TValue, in TScope extends ValidatorScope = ValidatorScope, > = (context: ValidationPredicateContext) => number +/** + * A change or blur event with an optional condition for running validation. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValue - Library-managed. Do not specify explicitly. + * @typeParam TTrigger - Library-managed. Do not specify explicitly. + * @typeParam TScope - Library-managed. Do not specify explicitly. + */ export interface ValidationTriggerConfig< in out TFormData, in out TValue, in out TTrigger extends ValidatorTrigger = ValidatorTrigger, in TScope extends ValidatorScope = ValidatorScope, > { + /** The event to match before evaluating `when`. */ trigger: TTrigger + /** + * Whether this trigger enables validation when its event occurs. + * + * Pass a boolean to enable or disable it, or pass a function that returns + * `true` to enable it for the current event. + * + * @default true + */ when?: boolean | ValidationPredicateFn } +/** + * A validation event, either unconditional or configured with a `when` condition. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValue - Library-managed. Do not specify explicitly. + * @typeParam TTrigger - Library-managed. Do not specify explicitly. + * @typeParam TScope - Library-managed. Do not specify explicitly. + */ export type ValidationTriggerOption< TFormData, TValue, @@ -345,6 +537,13 @@ export type ValidationTriggerOption< TScope extends ValidatorScope = ValidatorScope, > = TTrigger | ValidationTriggerConfig +/** + * A change or blur trigger, optionally enabled by a condition. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValue - Library-managed. Do not specify explicitly. + * @typeParam TScope - Library-managed. Do not specify explicitly. + */ export type ClientValidationTriggerOption< TFormData, TValue, @@ -356,6 +555,16 @@ export type ClientValidationTriggerOption< TScope > +/** + * A client event with an optional condition, or the `'server'` event for a form validator. + * + * Server validation selects validators with a `'server'` trigger independently + * of `runOnSubmit`. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TValue - Library-managed. Do not specify explicitly. + * @typeParam TScope - Library-managed. Do not specify explicitly. + */ export type FormValidationTriggerOption< TFormData, TValue, @@ -365,24 +574,43 @@ export type FormValidationTriggerOption< | ServerValidationTrigger /** - * A single validation error with a unique identifier. + * 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 validation error map. + * Creates a mutable map of form errors and errors for individual field paths. * - * If an initial error map is provided, the same object is returned. + * 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>, @@ -393,101 +621,300 @@ export function createErrorMap( 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 + +/** + * Values and helpers passed to a form validation function. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ export interface FormValidatorContext { + /** The event that caused this validation run. */ event: ValidationTrigger | ServerValidationTrigger + /** 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. */ formApi: FormApi | undefined + /** The field that caused validation, when available. */ 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. */ createErrorMap: CreateErrorMapFn + /** Creates a successful result to return data in this validator's `validatorOutputs` entry. */ + createOutput: CreateOutputFn } +/** + * Values and helpers passed to a server form validation function. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ export interface ServerFormValidatorContext { + /** The validation event. `serverValidate` supplies `'server'`. */ event: ValidationTrigger | ServerValidationTrigger + /** Aborted when this run is cancelled. Pass it to asynchronous work such as `fetch`. */ signal: AbortSignal + /** `undefined` during server validation, where no client form instance exists. */ formApi: FormApi | undefined + /** Absent during server validation, which is not triggered by a client field. */ 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. */ createErrorMap: CreateErrorMapFn + /** Creates a successful result to return data in this validator's server `validatorOutputs` entry. */ + createOutput: CreateOutputFn } +/** A validator return value that indicates success without providing output data. */ export type ValidValidationResult = null | undefined | false +/** A validator return value that indicates success, with or without output data. */ +export type SuccessfulValidationResult = + ValidValidationResult | ValidationOutput + /** - * Result of validation - can be null/undefined (valid), a single error, or multiple errors. + * One or more validation errors, or `null`, `undefined`, or `false` for success. */ 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 = - ValidationResult | ValidationErrorMap + SuccessfulValidationResult | FormValidationError +/** + * A validation callback that can return a result immediately or asynchronously. + * + * @typeParam TParameter - Library-managed. Do not specify explicitly. + * @typeParam TReturn - Library-managed. Do not specify explicitly. + */ export type ValidatorFn = ( ...args: Array ) => TReturn | Promise +/** + * Validates complete form values and can return form errors, field errors, or output data. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ export type FormValidatorFn = ValidatorFn< FormValidatorContext, FormValidateResult > +/** + * Validates submitted form values on the server without a client form instance. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ export type ServerFormValidatorFn = ValidatorFn< ServerFormValidatorContext, FormValidateResult > +/** + * Configures validation of complete form values on the client or server. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ export interface FormValidator extends BaseValidator< FormValidatorFn | StandardSchemaV1 > { + /** + * Whether this validator runs during a client submission attempt, independently of `triggers`. + * + * Pass a boolean to enable or disable it, or pass a function that returns + * `true` to enable it for the current attempt. + * + * @default true + */ runOnSubmit?: boolean | ValidationPredicateFn + /** + * Whether this validator runs when the form is constructed. + * + * @default false + */ runOnMount?: boolean + /** + * The delay in milliseconds before validation runs for change or blur events. + * + * Pass a number for a fixed delay, or pass a function that returns the delay + * for the current event. Repeated events restart the delay. + * Submission and server validation run immediately. + * + * @default 0 + */ triggerDebounceMs?: number | ValidationDebounceFn + /** + * Change and blur events that can run this validator, optionally with conditions. + * Add `'server'` to include it in `serverValidate`. + * + * An empty array disables change, blur, and server validation. Configure client + * submission separately with `runOnSubmit` and initial validation with `runOnMount`. + */ triggers: Array> } +/** + * Form validators in configuration order, which also determines their `validatorOutputs` indexes. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ export type FormValidators = ReadonlyArray> +/** + * Values and helpers passed to a form group validation function. + * + * @typeParam TGroupValue - Library-managed. Do not specify explicitly. + */ export interface FormGroupValidatorContext { + /** The event that caused this validation run. */ event: ClientValidationTrigger + /** Aborted when this run is cancelled. Pass it to asynchronous work such as `fetch`. */ signal: AbortSignal + /** The owning form, including values outside this group. */ formApi: FormApi + /** The group whose value is being validated. */ groupApi: FormGroupApi + /** The field that caused validation, when available. */ 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. */ createErrorMap: CreateErrorMapFn + /** Creates a successful result to return data in this validator's `validatorOutputs` entry. */ + createOutput: CreateOutputFn } +/** + * Errors for a form group, optionally including errors at group-relative field paths. + * + * @typeParam TGroupValue - Library-managed. Do not specify explicitly. + */ export type FormGroupValidationError = ValidationErrorInput | ValidationErrorMap +/** + * A form group validator's success result, output data, or validation errors. + * + * @typeParam TGroupValue - Library-managed. Do not specify explicitly. + */ export type FormGroupValidateResult = - ValidationResult | ValidationErrorMap + SuccessfulValidationResult | FormGroupValidationError +/** + * Validates a group's value and can return group errors, field errors, or output data. + * + * @typeParam TGroupValue - Library-managed. Do not specify explicitly. + */ export type FormGroupValidatorFn = ValidatorFn< FormGroupValidatorContext, FormGroupValidateResult > +/** + * Configures validation of a form group's value. + * + * @typeParam TGroupValue - Library-managed. Do not specify explicitly. + */ export interface FormGroupValidator extends Validator< TGroupValue, FormGroupValidatorFn | StandardSchemaV1, @@ -496,30 +923,63 @@ export interface FormGroupValidator extends Validator< 'group' > {} +/** + * Group validators in configuration order, which also determines their `validatorOutputs` indexes. + * + * @typeParam TGroupValue - Library-managed. Do not specify explicitly. + */ export type FormGroupValidators = ReadonlyArray< FormGroupValidator > +/** + * Values and helpers passed to a field validation function. + * + * @typeParam TFieldName - Library-managed. Do not specify explicitly. + * @typeParam TFieldValue - Library-managed. Do not specify explicitly. + * @typeParam TFormData - Library-managed. Do not specify explicitly. + */ export interface FieldValidatorContext< in out TFieldName, in out TFieldValue, in out TFormData, > { + /** The event that caused this validation run. */ event: ClientValidationTrigger + /** 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. */ formApi: FormApi + /** The field being validated, including when a watched field caused the event. */ 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. */ export type FieldValidateResult = ValidationResult +/** + * Validates a field's value and reports errors owned by that field. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFieldName - Library-managed. Do not specify explicitly. + * @typeParam TFieldValue - Library-managed. Do not specify explicitly. + */ export type FieldValidatorFn = ValidatorFn< FieldValidatorContext, FieldValidateResult > +/** + * Configures validation of a field's value, optionally reacting to related 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. + */ export interface FieldValidator< in out TFormData, in out TFieldName, @@ -532,15 +992,28 @@ export interface FieldValidator< ConfigurableValidationTrigger, 'field' > { + /** + * Other field paths whose change or blur events can run this validator. + * + * Events must still match an enabled entry in `triggers`. The validator + * receives its own field's value, even when a watched field caused the event. + */ watchFields?: Array> } +/** + * Field validators in configuration order. + * + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFieldName - Library-managed. Do not specify explicitly. + * @typeParam TFieldValue - Library-managed. Do not specify explicitly. + */ export type FieldValidators = ReadonlyArray< FieldValidator > type NormalizeValidationResult = NormalizeValidationError< - Exclude + Exclude > type NormalizeValidationError = @@ -569,11 +1042,19 @@ 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 } @@ -612,10 +1093,20 @@ type ExtractFormFieldError = ? 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 > +/** + * A field's error array, with string messages normalized to issue objects. + * + * @typeParam TFieldError - Library-managed. Do not specify explicitly. + */ export type FieldErrors = Array< unknown extends TFieldError ? ValidationIssue : TFieldError > @@ -629,6 +1120,12 @@ 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, @@ -645,14 +1142,56 @@ export type FormValidateResultFromErrorTypes< > } -type TryGetSchemaOutput = TValidator extends { +// Scalars and branded returns share the runtime success classification. Error +// collections additionally allow success when they contain no stored errors. +type SuccessWithoutOutput = + TResult extends SuccessfulValidationResult | void + ? TResult extends ValidationOutput + ? never + : undefined + : TResult extends ReadonlyArray + ? [] extends TResult + ? undefined + : never + : TResult extends ValidationIssue + ? never + : TResult extends ValidationErrorMap + ? TResult extends { form: infer TForm } + ? [SuccessWithoutOutput] extends [never] + ? never + : ErrorMapSuccess + : ErrorMapSuccess + : never + +type RequiredErrorKeys = { + [K in keyof TFields]-?: {} extends Pick + ? never + : [SuccessWithoutOutput] extends [never] + ? K + : never +}[keyof TFields] + +type ErrorMapSuccess = [RequiredErrorKeys] extends [never] + ? undefined + : never + +type OutputFromReturn = unknown extends TReturn + ? unknown + : [Extract>] extends [never] + ? undefined + : | (TReturn extends ValidationOutput ? TOutput : never) + | SuccessWithoutOutput + +type TryGetOutput = TValidator extends { readonly run: StandardSchemaV1 } ? TOutput - : undefined + : TValidator extends { readonly run: (...args: any) => infer TReturn } + ? OutputFromReturn> + : undefined -type TryGetSubmitSchemaOutput = - TryGetSchemaOutput extends infer TOutput +type TryGetSubmitOutput = + TryGetOutput extends infer TOutput ? TValidator extends { readonly runOnSubmit: infer TRunOnSubmit } ? [TRunOnSubmit] extends [false] ? undefined // User explicitly set runOnSubmit: false -> guaranteed undefined @@ -691,29 +1230,47 @@ type TryGetFieldError = TValidator extends { ? ExtractErrorMap, 'field'> : never -type MappedSchemaOutputs> = { +type MappedOutputs> = { [K in keyof TValidators]: TValidators[K] extends { readonly run: any } - ? TryGetSubmitSchemaOutput + ? TryGetSubmitOutput : never } -type ToSchemaOutputs< +type ToOutputs< TValidators extends ReadonlyArray, TBroadValidators extends ReadonlyArray, > = unknown extends TValidators ? Array : TBroadValidators extends TValidators ? Array - : MappedSchemaOutputs + : MappedOutputs -export type ToFormSchemaOutputs> = - ToSchemaOutputs> +/** + * 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> -export type ToFormGroupSchemaOutputs< +/** + * Infers submission output slots from group 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 TGroupValidators - Library-managed. Do not specify explicitly. + */ +export type ToFormGroupOutputs< TGroupValidators extends FormGroupValidators, -> = ToSchemaOutputs> +> = ToOutputs> type ExtractValidatorFormError< TValidators extends ReadonlyArray, @@ -744,11 +1301,25 @@ type ToValidatorErrorTypes< | 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, TSubmitReturn, > = ToValidatorErrorTypes, TSubmitReturn> +/** + * Infers group and routed field issue types from group validators. + * + * @typeParam TGroupValidators - Library-managed. Do not specify explicitly. + */ export type ToFormGroupErrorTypes< TGroupValidators extends FormGroupValidators, > = ToValidatorErrorTypes, never> @@ -757,6 +1328,15 @@ type FallbackToValidationIssue = [TFieldError] extends [never] ? ValidationIssue : TFieldError +/** + * Combines a field's own issue types with errors routed from its group and form. + * + * Falls back to `ValidationIssue` when no specific error type is inferred. + * + * @typeParam TFieldValidators - Library-managed. Do not specify explicitly. + * @typeParam TGroupFieldError - Library-managed. Do not specify explicitly. + * @typeParam TFormErrorTypes - Library-managed. Do not specify explicitly. + */ export type ToFieldError< TFieldValidators extends FieldValidators, TGroupFieldError, @@ -777,6 +1357,11 @@ type MappedServerFormValidators< : 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 @@ -785,20 +1370,25 @@ export type ToServerFormErrorTypes< ? FormErrorTypes : ToFormErrorTypes, never> -type MappedServerSchemaOutputs< - in out TFormValidators extends FormValidators, -> = { +type MappedServerOutputs> = { [K in keyof TFormValidators]: HasServerTrigger< TFormValidators[K] > extends true - ? TryGetSchemaOutput + ? TryGetOutput : undefined } -export type ServerFormStandardSchemaValidatorOutputs< - TFormValidators extends FormValidators, -> = unknown extends TFormValidators - ? Array - : FormValidators extends TFormValidators +/** + * Infers server validation output slots in form validator configuration order. + * + * Validators without a `'server'` trigger have `undefined` slots. Server + * selection is independent of `runOnSubmit`. + * + * @typeParam TFormValidators - Library-managed. Do not specify explicitly. + */ +export type ToServerFormOutputs> = + unknown extends TFormValidators ? Array - : MappedServerSchemaOutputs + : FormValidators extends TFormValidators + ? Array + : MappedServerOutputs diff --git a/packages/form-core/src/validation/errors.lib.ts b/packages/form-core/src/validation/errors.lib.ts index 4606bab024..7a96dec81a 100644 --- a/packages/form-core/src/validation/errors.lib.ts +++ b/packages/form-core/src/validation/errors.lib.ts @@ -1,4 +1,5 @@ -import { evaluate, isNil, normalizeToArray } from '../utils.lib' +import { evaluate, normalizeToArray } from '../utils.lib' +import { isSuccessfulValidationResult } from '../validationOutput.lib' import { compareValidationSources } from '../ValidationSourceInstance.lib' import type { AnyInternalValidationSourceInstance } from '../ValidationSourceInstance.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' @@ -6,6 +7,7 @@ import type { FieldValidateResult, FormGroupValidateResult, FormValidateResult, + SuccessfulValidationResult, ValidationErrorInput, ValidationErrorMap, ValidationIssue, @@ -58,7 +60,7 @@ export function isValidationErrorMap( export function parseValidationResult( value: ValidateResult, ): ParsedValidationResult { - if (isNil(value) || value === false) { + if (isSuccessfulValidationResult(value)) { return { self: null, subfields: null } } @@ -93,7 +95,7 @@ export function parseValidationResult( */ export function isErrorResult( value: T, -): value is Exclude { +): 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 150d0c4aeb..4b6160a36d 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -3,6 +3,7 @@ import { parseStandardSchema, parseStandardSchemaIssues, } 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 { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' @@ -41,7 +42,7 @@ type FieldValidateContext = Omit< > & { scope: 'field' } type FormGroupValidateContext = Omit< FormGroupValidatorContext, - 'value' | 'parseIssues' | 'createErrorMap' + 'value' | 'parseIssues' | 'createErrorMap' | 'createOutput' > & { scope: 'group' } export type FormInputContext = Omit type ServerFormInputContext = Omit @@ -161,8 +162,8 @@ export type AbortedCall = typeof ABORTED_CALL export type ThrownError = { [THROWN_ERROR]: true; error: unknown } export interface ValidatorExecutionResult { result: TResult - schemaResult: any | null - hasSchemaResult: boolean + output: any | null + hasOutput: boolean } interface PendingDebouncedCall { @@ -243,8 +244,7 @@ export function shouldRunValidator( /** * Executes a validator and normalizes its result for pipeline processing. * - * Standard Schema validators retain their parsed output and presence marker; - * function validators produce only a validation result. + * Schema and branded function outputs retain their payload and presence marker. */ export async function executeValidator( validator: AnyPipelineValidator, @@ -255,11 +255,22 @@ export async function executeValidator( return parseStandardSchema(validator.run, context.value, scope) as never } - return { - result: (await validator.run(context)) as TResult, - schemaResult: null, - hasSchemaResult: false, + return normalizeValidatorResult((await validator.run(context)) as TResult) +} + +/** Separates explicitly branded outputs from ordinary validation returns. */ +export function normalizeValidatorResult( + result: TResult, +): ValidatorExecutionResult { + if (isValidationOutput(result)) { + return { + result: null as TResult, + output: result[VALIDATION_OUTPUT], + hasOutput: true, + } } + + return { result, output: null, hasOutput: false } } interface RunMaybeDebouncedValidatorArgs< diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index 419e555e9b..79447996c6 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -1,3 +1,4 @@ +import { createOutput } from '../validationOutput.lib' import { createErrorMap } from '../validation.public' import { isStandardSchema, @@ -6,7 +7,11 @@ import { } from '../standardSchema.lib' import { isPromiseLike } from '../utils.lib' import { isErrorResult } from './errors.lib' -import { createValidatorAbortContext, parseFieldIssues } from './execution.lib' +import { + createValidatorAbortContext, + normalizeValidatorResult, + parseFieldIssues, +} from './execution.lib' import type { AnyInternalValidatorInstance } from '../ValidatorInstance.lib' import type { InternalFormApi } from '../FormApi/FormApi.lib' import type { AnyInternalFieldApi } from '../FieldApi/FieldApi.lib' @@ -21,8 +26,8 @@ import type { PipelineResult } from './pipeline.lib' type MountValidationExecutionResult = { result: TResult - schemaResult: any | null - hasSchemaResult: boolean + output: any | null + hasOutput: boolean } interface FormMountValidatorPipelineArgs { @@ -51,15 +56,15 @@ function createEmptyMountValidationResult< >(): MountValidationExecutionResult { return { result: null as TResult, - schemaResult: null, - hasSchemaResult: false, + output: null, + hasOutput: false, } } /** * Publishes one mount execution result and reports whether it contains errors. * - * Mount schema outputs remain on the immediate result and are not persisted on + * Mount outputs remain on the immediate result and are not persisted on * the validator instance. */ function processMountValidationExecutionResult( @@ -70,8 +75,8 @@ function processMountValidationExecutionResult( const result: PipelineResult = { validatorInstance, result: executionResult.result, - schemaResult: executionResult.schemaResult, - hasSchemaResult: executionResult.hasSchemaResult, + output: executionResult.output, + hasOutput: executionResult.hasOutput, } onResult?.(result) @@ -127,11 +132,7 @@ function executeMountValidator( return createEmptyMountValidationResult() } - return { - result: asyncResult as TResult, - schemaResult: null, - hasSchemaResult: false, - } + return normalizeValidatorResult(asyncResult as TResult) }) .catch((error) => { console.error(error) @@ -141,11 +142,7 @@ function executeMountValidator( } cleanup() - return { - result: result as TResult, - schemaResult: null, - hasSchemaResult: false, - } + return normalizeValidatorResult(result as TResult) } catch (error) { cleanup() console.error(error) @@ -299,6 +296,7 @@ export function runFormMountValidatorPipeline({ formApi, value: formApi.state.values, createErrorMap, + createOutput, parseIssues: (issues) => parseStandardSchemaIssues(issues, formApi.state.values, 'form'), }), @@ -358,6 +356,7 @@ export function runGroupMountValidatorPipeline({ triggerFieldApi: undefined, value: groupApi.value, createErrorMap, + createOutput, parseIssues: (issues) => parseStandardSchemaIssues(issues, groupApi.value, 'form'), }), diff --git a/packages/form-core/src/validation/pipeline.lib.ts b/packages/form-core/src/validation/pipeline.lib.ts index 9ba77b800b..e90bc50387 100644 --- a/packages/form-core/src/validation/pipeline.lib.ts +++ b/packages/form-core/src/validation/pipeline.lib.ts @@ -1,3 +1,4 @@ +import { createOutput } from '../validationOutput.lib' import { createErrorMap } from '../validation.public' import { parseStandardSchemaIssues } from '../standardSchema.lib' import { isNotNil } from '../utils.lib' @@ -33,8 +34,8 @@ import type { export interface PipelineResult { validatorInstance: AnyInternalValidatorInstance result: T - schemaResult: any | null - hasSchemaResult?: boolean + output: any | null + hasOutput?: boolean } interface PendingPipelineResult { @@ -63,13 +64,13 @@ type PendingPromises = Array< /** * Accepts a batch of pending results into the pipeline's instance-keyed map. * - * Aborted and thrown results are excluded. Submit schema output is committed + * Aborted and thrown results are excluded. Submit output is committed * before `onResult` observes the accepted result. */ async function flushPendingResults( pending: PendingPromises, results: Map>, - shouldCommitSchemaOutput: boolean, + shouldCommitOutput: boolean, onResult?: (result: PipelineResult) => void, ): Promise<{ hasErrors: boolean; thrownError: unknown | null }> { let hasErrors = false @@ -102,12 +103,12 @@ async function flushPendingResults( const publicResult: PipelineResult = { validatorInstance: result.validatorInstance, result: executionResult.result, - schemaResult: executionResult.schemaResult, - hasSchemaResult: executionResult.hasSchemaResult, + output: executionResult.output, + hasOutput: executionResult.hasOutput, } - if (shouldCommitSchemaOutput) { - result.validatorInstance.setSchemaOutput(executionResult) + if (shouldCommitOutput) { + result.validatorInstance.setOutput(executionResult) } results.set(result.validatorInstance, publicResult) onResult?.(publicResult) @@ -142,13 +143,13 @@ export async function runValidatorPipeline({ AnyInternalValidatorInstance, PipelineResult >() - const shouldCommitSchemaOutput = + const shouldCommitOutput = context.event === 'submit' && context.scope !== 'field' - if (shouldCommitSchemaOutput) { + if (shouldCommitOutput) { pipeline.forEach((validatorInstance) => { validatorInstance.cancelExecution() - validatorInstance.clearSchemaOutput() + validatorInstance.clearOutput() }) } @@ -157,12 +158,7 @@ export async function runValidatorPipeline({ const flush = async (): Promise => { const { hasErrors: didError, thrownError: flushedThrownError } = - await flushPendingResults( - pending, - results, - shouldCommitSchemaOutput, - onResult, - ) + await flushPendingResults(pending, results, shouldCommitOutput, onResult) pending = [] hasErrors ||= didError @@ -263,6 +259,7 @@ export function runFormValidatorPipeline({ signal: ctx.signal, value: ctx.formApi.state.values, createErrorMap, + createOutput, parseIssues: ( issues: Parameters[0], ) => diff --git a/packages/form-core/src/validationOutput.lib.ts b/packages/form-core/src/validationOutput.lib.ts new file mode 100644 index 0000000000..a1f2fc3c4d --- /dev/null +++ b/packages/form-core/src/validationOutput.lib.ts @@ -0,0 +1,31 @@ +import type { + SuccessfulValidationResult, + ValidationOutput, +} from './validation.public' + +export const VALIDATION_OUTPUT = Symbol('VALIDATION_OUTPUT') + +export function createOutput( + value: TOutput, +): ValidationOutput { + return { [VALIDATION_OUTPUT]: value } +} + +export function isValidationOutput( + value: unknown, +): value is ValidationOutput { + return ( + typeof value === 'object' && value !== null && VALIDATION_OUTPUT in value + ) +} + +export function isSuccessfulValidationResult( + value: unknown, +): value is SuccessfulValidationResult { + return ( + value === null || + value === undefined || + value === false || + isValidationOutput(value) + ) +} diff --git a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts index 9aca44432c..b2e02dcdba 100644 --- a/packages/form-core/tests/FieldApi/Lifecycle.spec.ts +++ b/packages/form-core/tests/FieldApi/Lifecycle.spec.ts @@ -173,16 +173,16 @@ describe('field - lifecycle', () => { const instance = field._validatorInstances?.[0] const abortController = new AbortController() instance?.setAbortController(abortController) - instance?.setSchemaOutput({ - schemaResult: 'output', - hasSchemaResult: true, + instance?.setOutput({ + output: 'output', + hasOutput: true, }) field.reset() expect(field._validatorInstances?.[0]).toBe(instance) expect(abortController.signal.aborted).toBe(true) - expect(instance?.hasSchemaOutput).toBe(false) + expect(instance?.hasOutput).toBe(false) expect(instance?.disposed).toBe(false) field._kill() @@ -536,7 +536,7 @@ describe('field - lifecycle', () => { { validatorInstance, result: { message: 'Too late' }, - schemaResult: null, + output: null, }, 'submit', ) diff --git a/packages/form-core/tests/FormApi/lifecycle.spec.ts b/packages/form-core/tests/FormApi/lifecycle.spec.ts index d3b4f5083c..28bd868b35 100644 --- a/packages/form-core/tests/FormApi/lifecycle.spec.ts +++ b/packages/form-core/tests/FormApi/lifecycle.spec.ts @@ -259,16 +259,16 @@ describe('form - lifecycle', () => { const instance = form._validatorInstances?.[0] const abortController = new AbortController() instance?.setAbortController(abortController) - instance?.setSchemaOutput({ - schemaResult: 'output', - hasSchemaResult: true, + instance?.setOutput({ + output: 'output', + hasOutput: true, }) form.reset() expect(form._validatorInstances?.[0]).toBe(instance) expect(abortController.signal.aborted).toBe(true) - expect(instance?.hasSchemaOutput).toBe(false) + expect(instance?.hasOutput).toBe(false) expect(instance?.disposed).toBe(false) }) diff --git a/packages/form-core/tests/FormApi/submission-handling.spec.ts b/packages/form-core/tests/FormApi/submission-handling.spec.ts index b191bb5de2..a756142fdb 100644 --- a/packages/form-core/tests/FormApi/submission-handling.spec.ts +++ b/packages/form-core/tests/FormApi/submission-handling.spec.ts @@ -472,7 +472,7 @@ describe('form - submission handling', () => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ value: { name: 'test' }, - schemaOutputs: [{ nameLength: 4 }, undefined], + validatorOutputs: [{ nameLength: 4 }, undefined], }), ) }) @@ -494,16 +494,16 @@ describe('form - submission handling', () => { await form.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( - expect.objectContaining({ schemaOutputs: [{ name: 'test' }] }), + expect.objectContaining({ validatorOutputs: [{ name: 'test' }] }), ) shouldRunOnSubmit = false await form.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( - expect.objectContaining({ schemaOutputs: [undefined] }), + expect.objectContaining({ validatorOutputs: [undefined] }), ) - expect(form._validatorInstances?.[0]?.hasSchemaOutput).toBe(false) + expect(form._validatorInstances?.[0]?.hasOutput).toBe(false) }) it('skips bailIfInvalid form validators when field validators fail first', async () => { diff --git a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts index afa3e168e8..74c906f0ff 100644 --- a/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts @@ -406,9 +406,9 @@ describe('FormGroupApi', () => { const instance = group._validatorInstances?.[0] const abortController = new AbortController() instance?.setAbortController(abortController) - instance?.setSchemaOutput({ - schemaResult: 'output', - hasSchemaResult: true, + instance?.setOutput({ + output: 'output', + hasOutput: true, }) group._cleanup() @@ -416,7 +416,7 @@ describe('FormGroupApi', () => { expect(group._validatorInstances?.[0]).toBe(instance) expect(abortController.signal.aborted).toBe(true) - expect(instance?.hasSchemaOutput).toBe(false) + expect(instance?.hasOutput).toBe(false) expect(instance?.disposed).toBe(false) }) @@ -882,7 +882,7 @@ describe('FormGroupApi', () => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ - schemaOutputs: [{ nameLength: 4 }], + validatorOutputs: [{ nameLength: 4 }], value: { name: 'Tony' }, }), ) @@ -909,16 +909,16 @@ describe('FormGroupApi', () => { await group.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( - expect.objectContaining({ schemaOutputs: [{ name: 'Tony' }] }), + expect.objectContaining({ validatorOutputs: [{ name: 'Tony' }] }), ) shouldRunOnSubmit = false await group.handleSubmit() expect(onSubmit).toHaveBeenLastCalledWith( - expect.objectContaining({ schemaOutputs: [undefined] }), + expect.objectContaining({ validatorOutputs: [undefined] }), ) - expect(group._validatorInstances?.[0]?.hasSchemaOutput).toBe(false) + expect(group._validatorInstances?.[0]?.hasOutput).toBe(false) }) it('keeps submission lifecycle independent between sibling groups', async () => { diff --git a/packages/form-core/tests/ValidatorInstance.spec.ts b/packages/form-core/tests/ValidatorInstance.spec.ts index 9bf5dc10a8..a77caa3515 100644 --- a/packages/form-core/tests/ValidatorInstance.spec.ts +++ b/packages/form-core/tests/ValidatorInstance.spec.ts @@ -56,8 +56,8 @@ describe('InternalValidatorInstance', () => { expect(first.index).toBe(0) expect(first.abortController).toBeNull() expect(first.debouncer).toBeNull() - expect(first.schemaOutput).toBeUndefined() - expect(first.hasSchemaOutput).toBe(false) + expect(first.output).toBeUndefined() + expect(first.hasOutput).toBe(false) expect(first.errorTargets).toBeNull() expect(first.resolvedWatchFields).toBeNull() expect(first.didRunOnMount).toBe(false) @@ -81,7 +81,7 @@ describe('InternalValidatorInstance', () => { string, { name: string } > | null>() - expectTypeOf(first.schemaOutput).toEqualTypeOf() + expectTypeOf(first.output).toEqualTypeOf() expectTypeOf( first.debouncer, ).toEqualTypeOf | null>() @@ -96,9 +96,9 @@ describe('InternalValidatorInstance', () => { const watchedField = { name: 'source' } instance.setAbortController(abortController) - instance.setSchemaOutput({ - schemaResult: 'output', - hasSchemaResult: true, + instance.setOutput({ + output: 'output', + hasOutput: true, }) instance.addErrorTarget('target') instance.setResolvedWatchField('source', watchedField) @@ -114,8 +114,8 @@ describe('InternalValidatorInstance', () => { expect(instance.revision).toBe(2) expect(instance.abortController).toBe(abortController) expect(instance.debouncer).toBe(debouncer) - expect(instance.schemaOutput).toBe('output') - expect(instance.hasSchemaOutput).toBe(true) + expect(instance.output).toBe('output') + expect(instance.hasOutput).toBe(true) expect(instance.errorTargets).toEqual(new Set(['target'])) expect(instance.resolvedWatchFields?.get('source')).toBe(watchedField) expect(instance.didRunOnMount).toBe(true) @@ -176,23 +176,23 @@ describe('InternalValidatorInstance', () => { it('distinguishes an unset schema output from an undefined output', () => { const instance = createInstance() - instance.setSchemaOutput({ - schemaResult: 'ignored', - hasSchemaResult: false, + instance.setOutput({ + output: 'ignored', + hasOutput: false, }) - expect(instance.schemaOutput).toBeUndefined() - expect(instance.hasSchemaOutput).toBe(false) + expect(instance.output).toBeUndefined() + expect(instance.hasOutput).toBe(false) - instance.setSchemaOutput({ - schemaResult: undefined, - hasSchemaResult: true, + instance.setOutput({ + output: undefined, + hasOutput: true, }) - expect(instance.schemaOutput).toBeUndefined() - expect(instance.hasSchemaOutput).toBe(true) + expect(instance.output).toBeUndefined() + expect(instance.hasOutput).toBe(true) - instance.clearSchemaOutput() - expect(instance.schemaOutput).toBeUndefined() - expect(instance.hasSchemaOutput).toBe(false) + instance.clearOutput() + expect(instance.output).toBeUndefined() + expect(instance.hasOutput).toBe(false) }) it('resets runtime state while preserving its installation', async () => { @@ -207,9 +207,9 @@ describe('InternalValidatorInstance', () => { instance.updateDefinition(definition) instance.setAbortController(abortController) const debouncer = instance.getOrCreateDebouncer(debouncedFn, 100) - instance.setSchemaOutput({ - schemaResult: 'output', - hasSchemaResult: true, + instance.setOutput({ + output: 'output', + hasOutput: true, }) instance.addErrorTarget('target') instance.setResolvedWatchField('source', watchedField) @@ -223,8 +223,8 @@ describe('InternalValidatorInstance', () => { expect(debouncedFn).not.toHaveBeenCalled() expect(instance.abortController).toBeNull() expect(instance.debouncer).toBeNull() - expect(instance.schemaOutput).toBeUndefined() - expect(instance.hasSchemaOutput).toBe(false) + expect(instance.output).toBeUndefined() + expect(instance.hasOutput).toBe(false) expect(instance.errorTargets).toBeNull() expect(instance.resolvedWatchFields?.get('source')).toBe(watchedField) expect(instance.didRunOnMount).toBe(true) @@ -244,9 +244,9 @@ describe('InternalValidatorInstance', () => { instance.setAbortController(abortController) const debouncer = instance.getOrCreateDebouncer(debouncedFn, 100) - instance.setSchemaOutput({ - schemaResult: 'output', - hasSchemaResult: true, + instance.setOutput({ + output: 'output', + hasOutput: true, }) instance.addErrorTarget('target') instance.setResolvedWatchField('source', { name: 'source' }) @@ -269,8 +269,8 @@ describe('InternalValidatorInstance', () => { expect(debouncedFn).not.toHaveBeenCalled() expect(instance.abortController).toBeNull() expect(instance.debouncer).toBeNull() - expect(instance.schemaOutput).toBeUndefined() - expect(instance.hasSchemaOutput).toBe(false) + expect(instance.output).toBeUndefined() + expect(instance.hasOutput).toBe(false) expect(instance.errorTargets).toBeNull() expect(instance.resolvedWatchFields).toBeNull() expect(instance.didRunOnMount).toBe(false) @@ -282,11 +282,11 @@ describe('InternalValidatorInstance', () => { instance.setAbortController(nextController) instance.clearAbortController(nextController) const nextDebouncer = instance.getOrCreateDebouncer(nextDebouncedFn, 100) - instance.setSchemaOutput({ - schemaResult: 'ignored', - hasSchemaResult: true, + instance.setOutput({ + output: 'ignored', + hasOutput: true, }) - instance.clearSchemaOutput() + instance.clearOutput() instance.addErrorTarget('ignored') instance.deleteErrorTarget('target') instance.setResolvedWatchField('ignored', { name: 'ignored' }) @@ -299,7 +299,7 @@ describe('InternalValidatorInstance', () => { expect(instance.revision).toBe(0) expect(instance.abortController).toBeNull() expect(instance.debouncer).toBeNull() - expect(instance.hasSchemaOutput).toBe(false) + expect(instance.hasOutput).toBe(false) expect(instance.errorTargets).toBeNull() expect(instance.resolvedWatchFields).toBeNull() expect(instance.didRunOnMount).toBe(false) diff --git a/packages/form-core/tests/serverValidate.spec.ts b/packages/form-core/tests/serverValidate.spec.ts index b2c6fa92bb..5a6bb81f1a 100644 --- a/packages/form-core/tests/serverValidate.spec.ts +++ b/packages/form-core/tests/serverValidate.spec.ts @@ -198,10 +198,7 @@ describe('server validation', () => { }) it('returns values and schema outputs when server validation succeeds', async () => { - const setSchemaOutput = vi.spyOn( - InternalValidatorInstance.prototype, - 'setSchemaOutput', - ) + const setOutput = vi.spyOn(InternalValidatorInstance.prototype, 'setOutput') const options = formOptions({ defaultValues: { name: '' }, validators: [ @@ -222,10 +219,10 @@ describe('server validation', () => { ) expect(result.values).toEqual({ name: 'Tony' }) - expect(result.schemaOutputs).toEqual([{ nameLength: 4 }]) - expect(setSchemaOutput).not.toHaveBeenCalled() + expect(result.validatorOutputs).toEqual([{ nameLength: 4 }]) + expect(setOutput).not.toHaveBeenCalled() } finally { - setSchemaOutput.mockRestore() + setOutput.mockRestore() } }) @@ -254,7 +251,7 @@ describe('server validation', () => { await validateServerValues(options, { name: 'Tony' }), ) - expect(result.schemaOutputs).toEqual([ + expect(result.validatorOutputs).toEqual([ undefined, undefined, { nameLength: 4 }, @@ -541,7 +538,7 @@ describe('server validation', () => { { validatorIndex: 0, result: 'Server name error', - schemaResult: null, + output: null, }, ], submissionAttempts: 1, @@ -569,8 +566,8 @@ describe('server validation', () => { { validatorIndex: 0, result: null, - schemaResult: undefined, - hasSchemaResult: true, + output: undefined, + hasOutput: true, }, ], submissionAttempts: 1, @@ -579,8 +576,8 @@ describe('server validation', () => { const form = new InternalFormApi(options) - expect(form._validatorInstances?.[0]?.hasSchemaOutput).toBe(false) - expect(form._validatorInstances?.[0]?.schemaOutput).toBeUndefined() + expect(form._validatorInstances?.[0]?.hasOutput).toBe(false) + expect(form._validatorInstances?.[0]?.output).toBeUndefined() }) it('notifies Devtools when server state directly resets field meta', () => { diff --git a/packages/form-core/tests/serverValidate.test-d.ts b/packages/form-core/tests/serverValidate.test-d.ts index a5147d5a1b..a126b87415 100644 --- a/packages/form-core/tests/serverValidate.test-d.ts +++ b/packages/form-core/tests/serverValidate.test-d.ts @@ -36,7 +36,7 @@ describe('server validation types', () => { } expectTypeOf(result.values).toEqualTypeOf<{ name: string }>() - expectTypeOf(result.schemaOutputs).toEqualTypeOf< + expectTypeOf(result.validatorOutputs).toEqualTypeOf< readonly [{ nameLength: number }] >() diff --git a/packages/form-core/tests/standardSchema.spec.ts b/packages/form-core/tests/standardSchema.spec.ts index 745d363c25..85210038eb 100644 --- a/packages/form-core/tests/standardSchema.spec.ts +++ b/packages/form-core/tests/standardSchema.spec.ts @@ -136,8 +136,8 @@ describe('standard schema validation', () => { parseStandardSchema(schema, 'value', 'form'), ).resolves.toEqual({ result: null, - schemaResult: { parsed: 'VALUE' }, - hasSchemaResult: true, + output: { parsed: 'VALUE' }, + hasOutput: true, }) }) @@ -149,8 +149,8 @@ describe('standard schema validation', () => { await expect(parseStandardSchema(schema, '', 'field')).resolves.toEqual({ result: issues, - schemaResult: null, - hasSchemaResult: false, + output: null, + hasOutput: false, }) }) @@ -169,8 +169,8 @@ describe('standard schema validation', () => { 'users[0].name': issues, }, }, - schemaResult: null, - hasSchemaResult: false, + output: null, + hasOutput: false, }) }) @@ -191,8 +191,8 @@ describe('standard schema validation', () => { name: [issues[2]], }, }, - schemaResult: null, - hasSchemaResult: false, + output: null, + hasOutput: false, }) }) diff --git a/packages/form-core/tests/validation-output.test-d.ts b/packages/form-core/tests/validation-output.test-d.ts new file mode 100644 index 0000000000..8c3e80f214 --- /dev/null +++ b/packages/form-core/tests/validation-output.test-d.ts @@ -0,0 +1,289 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { formOptions } from '../src' +import { validateServerValues } from '../src/internals' +import type { + FieldValidatorContext, + FieldValidators, + FormGroupValidators, + FormValidators, + ToFormErrorTypes, + ToFormGroupErrorTypes, + ToFormGroupOutputs, + ToFormOutputs, + ValidationErrorMap, + ValidationIssue, + ValidationOutput, +} from '../src' + +type Data = { name: string } + +function defineValidators>( + validators: T, +): T { + return validators +} + +function defineGroupValidators>( + validators: T, +): T { + return validators +} + +describe('validator output types', () => { + it('infers mixed schema and sync/async function outputs without raw error returns', () => { + formOptions({ + defaultValues: { name: '' }, + validators: [ + { run: z.object({ name: z.string() }), triggers: [] }, + { + run: ({ value, createOutput }) => createOutput(value.name.length), + triggers: [], + }, + { run: () => undefined, triggers: [] }, + { + run: async ({ value, createOutput }) => { + if (!value.name) return 'Required' + return createOutput({ name: value.name }) + }, + triggers: [], + }, + ], + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< + readonly [{ name: string }, number, undefined, { name: string }] + >() + }, + }) + }) + + it('excludes branded payloads from form and routed field error inference', () => { + const validators = defineValidators([ + { + run: ({ value, createOutput }) => { + if (!value.name) + return { fields: { name: { message: 'Required', code: 400 } } } + return createOutput({ + message: 'Data', + fields: { name: { message: 'Data', payloadOnly: true } }, + }) + }, + triggers: [], + }, + ]) + + expectTypeOf>().toEqualTypeOf<{ + readonly formError: never + readonly fieldError: { message: string; code: number } + }>() + expectTypeOf>().toEqualTypeOf< + readonly [ + { + message: string + fields: { name: { message: string; payloadOnly: boolean } } + }, + ] + >() + + const outputOnly = defineValidators([ + { run: ({ createOutput }) => createOutput('data'), triggers: [] }, + ]) + expectTypeOf>().toEqualTypeOf<{ + readonly formError: never + readonly fieldError: never + }>() + }) + + it('includes undefined for successful scalar returns without an output', () => { + const validators = defineValidators([ + { + run: ({ value, createOutput }) => + value.name ? createOutput(1) : undefined, + triggers: [], + }, + { + run: ({ value, createOutput }) => (value.name ? createOutput(1) : null), + triggers: [], + }, + { + run: ({ value, createOutput }) => + value.name ? createOutput(1) : false, + triggers: [], + }, + { run: ({ createOutput }) => createOutput(undefined), triggers: [] }, + ]) + + expectTypeOf>().toEqualTypeOf< + readonly [ + number | undefined, + number | undefined, + number | undefined, + undefined, + ] + >() + }) + + it('accounts for empty collections without discarding their possible errors', () => { + const validators = defineValidators([ + { + run: ({ createOutput }): ValidationOutput | ValidationIssue[] => + createOutput(1), + triggers: [], + }, + { + run: ({ + createOutput, + }): ValidationOutput | ValidationErrorMap => + createOutput(1), + triggers: [], + }, + { + run: ({ createOutput }): ValidationOutput | [ValidationIssue] => + createOutput(1), + triggers: [], + }, + { + run: ({ + createOutput, + }): ValidationOutput | { fields: { name: string } } => + createOutput(1), + triggers: [], + }, + { + run: ({ + createOutput, + }): ValidationOutput | { fields: { name?: string } } => + createOutput(1), + triggers: [], + }, + { + run: ({ + createOutput, + }): ValidationOutput | { form: string; fields: {} } => + createOutput(1), + triggers: [], + }, + { + run: ({ + createOutput, + }): + | ValidationOutput + | { form: []; fields: { name: undefined } } => createOutput(1), + triggers: [], + }, + { + run: ({ + createOutput, + }): ValidationOutput | { message: string; fields: {} } => + createOutput(1), + triggers: [], + }, + ]) + + expectTypeOf>().toEqualTypeOf< + readonly [ + number | undefined, + number | undefined, + number, + number, + number | undefined, + number, + number | undefined, + number, + ] + >() + type Errors = ToFormErrorTypes + expectTypeOf().toExtend() + expectTypeOf().toExtend() + expectTypeOf().toExtend() + }) + + it('preserves submit eligibility and conservative broad validator outputs', () => { + const validators = defineValidators([ + { + run: ({ createOutput }) => createOutput(1), + triggers: [], + runOnSubmit: false, + }, + { + run: ({ createOutput }) => createOutput(1), + triggers: [], + runOnSubmit: true as boolean, + }, + { + run: ({ createOutput }) => createOutput(1), + triggers: [], + runOnSubmit: () => true, + }, + { + run: ({ createOutput }) => createOutput(1), + triggers: [], + runOnSubmit: true, + }, + ]) + + expectTypeOf>().toEqualTypeOf< + readonly [undefined, number | undefined, number | undefined, number] + >() + expectTypeOf>>().toEqualTypeOf< + unknown[] + >() + }) + + it('infers group outputs and excludes them from group errors', () => { + const validators = defineGroupValidators([ + { + run: async ({ value, createOutput }) => { + if (!value.name) return 'Required' + return createOutput(value.name.length) + }, + triggers: [], + }, + ]) + + expectTypeOf>().toEqualTypeOf< + readonly [number] + >() + expectTypeOf>().toEqualTypeOf<{ + readonly formError: ValidationIssue + readonly fieldError: ValidationIssue + }>() + }) + + it('infers server outputs independently of client submit eligibility', async () => { + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: ({ createOutput }) => createOutput('client'), + triggers: ['change'], + }, + { + run: async ({ value, createOutput }) => + createOutput(value.name.length), + triggers: ['server'], + runOnSubmit: false, + }, + ], + }) + const result = await validateServerValues(options, { name: 'Tony' }) + if (!result.success) throw new Error('Expected success') + + expectTypeOf(result.validatorOutputs).toEqualTypeOf< + readonly [undefined, number] + >() + }) + + it('does not expose or accept branded outputs in field validators', () => { + expectTypeOf<'createOutput'>().not.toExtend< + keyof FieldValidatorContext<'name', string, Data> + >() + type OutputValidator = { + run: () => ValidationOutput + triggers: [] + } + expectTypeOf().not.toExtend< + FieldValidators + >() + }) +}) diff --git a/packages/form-core/tests/validation-output.test.ts b/packages/form-core/tests/validation-output.test.ts new file mode 100644 index 0000000000..6dac7bd53f --- /dev/null +++ b/packages/form-core/tests/validation-output.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { z } from 'zod' +import { formOptions } from '../src' +import { InternalFormApi } from '../src/FormApi/FormApi.lib' +import { InternalFormGroupApi } from '../src/FormGroupApi/FormGroupApi.lib' +import { validateServerValues } from '../src/internals' +import type { FormValidatorContext } from '../src' + +describe('validator outputs', () => { + it('forwards arbitrary payloads without mutation or loss of identity', async () => { + const payloads = [ + undefined, + null, + false, + 0, + '', + 1n, + Symbol('payload'), + Object.freeze({ message: 'This is data', fields: { name: 'Also data' } }), + Object.freeze(['data']), + () => 'data', + ] + const onSubmit = vi.fn() + const form = new InternalFormApi( + formOptions({ + defaultValues: { name: 'Tony' }, + validators: payloads.map((payload) => ({ + run: ({ createOutput }: FormValidatorContext<{ name: string }>) => + createOutput(payload), + triggers: [], + })), + onSubmit, + }), + ) + + await form.handleSubmit() + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + value: { name: 'Tony' }, + validatorOutputs: payloads, + }), + ) + const { validatorOutputs } = onSubmit.mock.lastCall![0] + for (const [index, payload] of payloads.entries()) { + expect(validatorOutputs[index]).toBe(payload) + } + expect(form.state.errors).toEqual([]) + expect(form.state.isSubmitSuccessful).toBe(true) + }) + + it('aligns schema, sync, async, absent, and skipped outputs by validator index', async () => { + const skipped = vi.fn() + const onSubmit = vi.fn() + const form = new InternalFormApi( + formOptions({ + defaultValues: { name: ' Tony ' }, + validators: [ + { run: z.object({ name: z.string().trim() }), triggers: [] }, + { + run: ({ value, createOutput }) => createOutput(value.name.length), + triggers: [], + }, + { + run: async ({ value, createOutput }) => + createOutput(value.name.trim().toUpperCase()), + triggers: [], + }, + { run: () => undefined, triggers: [] }, + { run: skipped, triggers: [], runOnSubmit: false }, + ], + onSubmit, + }), + ) + + await form.handleSubmit() + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + value: { name: ' Tony ' }, + validatorOutputs: [{ name: 'Tony' }, 6, 'TONY', undefined, undefined], + }), + ) + expect(skipped).not.toHaveBeenCalled() + }) + + it('keeps error branches as errors and accepts a later output branch', async () => { + const onSubmit = vi.fn() + const form = new InternalFormApi( + formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: ({ value, createOutput }) => { + if (!value.name) return { message: 'Required', code: 'required' } + return createOutput({ name: value.name.trim() }) + }, + triggers: [], + }, + ], + onSubmit, + }), + ) + + await form.handleSubmit() + + expect(onSubmit).not.toHaveBeenCalled() + expect(form.state.errors).toEqual([ + { message: 'Required', code: 'required' }, + ]) + + form.setFieldValue('name', ' Tony ') + await form.handleSubmit() + + expect(form.state.errors).toEqual([]) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ validatorOutputs: [{ name: 'Tony' }] }), + ) + }) + + it('clears earlier outputs when a later submission skips or succeeds without output', async () => { + let shouldRun = true + let produceOutput = true + const onSubmit = vi.fn() + const form = new InternalFormApi( + formOptions({ + defaultValues: { name: 'Tony' }, + validators: [ + { + run: ({ value, createOutput }) => + produceOutput ? createOutput(value.name) : [], + runOnSubmit: () => shouldRun, + triggers: [], + }, + ], + onSubmit, + }), + ) + + await form.handleSubmit() + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ validatorOutputs: ['Tony'] }), + ) + + shouldRun = false + await form.handleSubmit() + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ validatorOutputs: [undefined] }), + ) + + shouldRun = true + await form.handleSubmit() + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ validatorOutputs: ['Tony'] }), + ) + + produceOutput = false + await form.handleSubmit() + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ validatorOutputs: [undefined] }), + ) + }) + + it('treats change and blur outputs as success without retaining them for submission', async () => { + const onSubmit = vi.fn() + const nextValidator = vi.fn(() => undefined) + const form = new InternalFormApi( + formOptions({ + defaultValues: { name: 'Tony' }, + validators: [ + { + run: ({ createOutput }) => createOutput('event output'), + triggers: ['change', 'blur'], + runOnSubmit: false, + }, + { + run: nextValidator, + triggers: ['change', 'blur'], + bailIfInvalid: true, + }, + ], + onSubmit, + }), + ) + + const changeErrors = await form.validate('change') + const blurErrors = await form.validate('blur') + + expect(changeErrors).toEqual([]) + expect(blurErrors).toEqual([]) + expect(nextValidator).toHaveBeenCalledWith( + expect.objectContaining({ event: 'blur' }), + ) + + await form.handleSubmit() + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ validatorOutputs: [undefined, undefined] }), + ) + }) + + it('treats sync and async mount outputs as success and preserves synchronous mount work', async () => { + for (const asyncOutput of [false, true]) { + const nextValidator = vi.fn(() => undefined) + const onSubmit = vi.fn() + const form = new InternalFormApi( + formOptions({ + defaultValues: { name: 'Tony' }, + validators: [ + { + run: ({ createOutput }) => + asyncOutput + ? Promise.resolve(createOutput('mount output')) + : createOutput('mount output'), + runOnMount: true, + runOnSubmit: false, + triggers: [], + }, + { + run: nextValidator, + runOnMount: true, + triggers: [], + bailIfInvalid: true, + }, + ], + onSubmit, + }), + ) + + const cleanup = form.mount() + onTestFinished(cleanup) + if (!asyncOutput) expect(nextValidator).toHaveBeenCalled() + await expect.poll(() => form.state.isValidating).toBe(false) + + expect(nextValidator).toHaveBeenCalledWith( + expect.objectContaining({ event: 'mount' }), + ) + expect(form.state.errors).toEqual([]) + + await form.handleSubmit() + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ validatorOutputs: [undefined, undefined] }), + ) + } + }) + + it('provides group outputs from fresh validation and clears skipped slots', async () => { + let shouldRun = true + const onSubmit = vi.fn() + const form = new InternalFormApi({ + defaultValues: { guest: { name: ' Tony ' } }, + }) + const group = new InternalFormGroupApi({ + form, + name: 'guest', + validators: [ + { + run: async ({ value, createOutput }) => + createOutput({ name: value.name.trim() }), + runOnMount: true, + runOnSubmit: () => shouldRun, + triggers: ['change'], + }, + ], + onSubmit, + }) + + group.mount() + onTestFinished(() => group._cleanup()) + await expect.poll(() => group.state.isValidating).toBe(false) + const errors = await group.validate('change') + expect(errors).toEqual([]) + expect(group.state.errors).toEqual([]) + + await group.handleSubmit() + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ validatorOutputs: [{ name: 'Tony' }] }), + ) + + shouldRun = false + await group.handleSubmit() + expect(onSubmit).toHaveBeenLastCalledWith( + expect.objectContaining({ validatorOutputs: [undefined] }), + ) + }) + + it('returns separate server outputs only for server-triggered validators', async () => { + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: ({ createOutput }) => createOutput('client'), + triggers: ['change'], + }, + { run: z.object({ name: z.string().trim() }), triggers: ['server'] }, + { + run: async ({ value, createOutput }) => + createOutput(value.name.length), + triggers: ['server'], + runOnSubmit: false, + }, + ], + }) + + const result = await validateServerValues(options, { name: ' Tony ' }) + + expect(result).toEqual({ + success: true, + values: { name: ' Tony ' }, + validatorOutputs: [undefined, { name: 'Tony' }, 6], + }) + }) + + it('consumes output wrappers before serializing failed server validation', async () => { + const options = formOptions({ + defaultValues: { name: '' }, + validators: [ + { + run: ({ createOutput }) => createOutput({ name: 'Tony' }), + triggers: ['server'], + }, + { run: () => 'Rejected', triggers: ['server'] }, + ], + }) + + const result = await validateServerValues(options, { name: 'Tony' }) + + expect(result.success).toBe(false) + if (result.success) throw new Error('Expected failure') + const serialized = JSON.parse(JSON.stringify(result.serverState)) + expect(serialized.validationResults).toEqual([ + { + validatorIndex: 0, + result: null, + output: { name: 'Tony' }, + hasOutput: true, + }, + { validatorIndex: 1, result: 'Rejected', output: null, hasOutput: false }, + ]) + }) +}) diff --git a/packages/form-core/tests/validation-pipeline.test.ts b/packages/form-core/tests/validation-pipeline.test.ts index c9443d8a65..dbff7c12b9 100644 --- a/packages/form-core/tests/validation-pipeline.test.ts +++ b/packages/form-core/tests/validation-pipeline.test.ts @@ -16,7 +16,7 @@ import type { FormValidateResult, FormValidator, FormValidatorContext, - ToFormSchemaOutputs, + ToFormOutputs, ValidationDebounceFn, ValidationPredicateFn, } from '../src' @@ -111,7 +111,7 @@ describe('runFormValidatorPipeline', () => { expect(results).toContainEqual( expect.objectContaining({ result: { message: 'foo' }, - schemaResult: null, + output: null, }), ) }) @@ -790,7 +790,7 @@ describe('runFormValidatorPipeline', () => { }) .transform(({ name }) => ({ upperName: name.toUpperCase() })) - type Output = ToFormSchemaOutputs< + type Output = ToFormOutputs< [ { run: typeof lengthSchema; triggers: [] }, { run: typeof uppercaseSchema; triggers: [] }, @@ -819,9 +819,9 @@ describe('runFormValidatorPipeline', () => { new InternalFormApi({ defaultValues: { name: '' }, validators: [{ run: schema, triggers: [] }], - onSubmit: ({ value, schemaOutputs }) => { + onSubmit: ({ value, validatorOutputs }) => { expectTypeOf(value).toEqualTypeOf<{ name: string }>() - expectTypeOf(schemaOutputs).toEqualTypeOf< + expectTypeOf(validatorOutputs).toEqualTypeOf< readonly [{ nameLength: number }] >() }, @@ -840,9 +840,9 @@ describe('runFormValidatorPipeline', () => { { run: () => false, triggers: [] }, { run: schema2, triggers: [] }, ], - onSubmit: ({ value, schemaOutputs }) => { + onSubmit: ({ value, validatorOutputs }) => { expectTypeOf(value).toEqualTypeOf<{ name: string }>() - expectTypeOf(schemaOutputs).toEqualTypeOf< + expectTypeOf(validatorOutputs).toEqualTypeOf< readonly [{ nameLength: number }, undefined, { date: Date }] >() }, @@ -867,7 +867,7 @@ describe('runFormValidatorPipeline', () => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ value: { name: 'test' }, - schemaOutputs: [{ nameLength: 4 }], + validatorOutputs: [{ nameLength: 4 }], }), ) }) @@ -925,12 +925,12 @@ describe('runFormValidatorPipeline', () => { expect(results).toHaveLength(1) expect(results[0]?.result).toBeNull() - expect(results[0]?.schemaResult).toEqual({ + expect(results[0]?.output).toEqual({ name: 'test', age: 25, }) - expect(pipeline[0]?.hasSchemaOutput).toBe(true) - expect(pipeline[0]?.schemaOutput).toEqual({ + expect(pipeline[0]?.hasOutput).toBe(true) + expect(pipeline[0]?.output).toEqual({ name: 'test', age: 25, }) @@ -950,8 +950,8 @@ describe('runFormValidatorPipeline', () => { const results = await runWithContext({ event }) - expect(results[0]?.schemaResult).toEqual({ name: 'test' }) - expect(pipeline[0]?.hasSchemaOutput).toBe(false) + expect(results[0]?.output).toEqual({ name: 'test' }) + expect(pipeline[0]?.hasOutput).toBe(false) }, ) @@ -968,8 +968,8 @@ describe('runFormValidatorPipeline', () => { const results = await runWithContext({ event: 'submit' }) - expect(results[0]?.schemaResult).toBe('TEST') - expect(pipeline[0]?.hasSchemaOutput).toBe(false) + expect(results[0]?.output).toBe('TEST') + expect(pipeline[0]?.hasOutput).toBe(false) }) it('commits schema output before notifying onResult', async () => { @@ -982,8 +982,8 @@ describe('runFormValidatorPipeline', () => { }, ]) const onResult = vi.fn(() => { - expect(pipeline[0]?.hasSchemaOutput).toBe(true) - expect(pipeline[0]?.schemaOutput).toEqual({ name: 'test' }) + expect(pipeline[0]?.hasOutput).toBe(true) + expect(pipeline[0]?.output).toEqual({ name: 'test' }) }) await runWithContext({ event: 'submit', onResult }) @@ -1021,12 +1021,12 @@ describe('runFormValidatorPipeline', () => { const currentValidation = runWithContext({ event: 'submit' }) await Promise.all([staleValidation, currentValidation]) - expect(pipeline[0]?.schemaOutput).toEqual({ name: 'current' }) + expect(pipeline[0]?.output).toEqual({ name: 'current' }) resolveFirst({ value: { name: 'stale' } }) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(pipeline[0]?.schemaOutput).toEqual({ name: 'current' }) + expect(pipeline[0]?.output).toEqual({ name: 'current' }) }) it('clears schema output before a validator is skipped by bailIfInvalid', async () => { @@ -1045,29 +1045,27 @@ describe('runFormValidatorPipeline', () => { ]) await runWithContext({ event: 'submit' }) - expect(pipeline[1]?.hasSchemaOutput).toBe(true) + expect(pipeline[1]?.hasOutput).toBe(true) shouldFail = true await runWithContext({ event: 'submit' }) - expect(pipeline[1]?.hasSchemaOutput).toBe(false) - expect(pipeline[1]?.schemaOutput).toBeUndefined() + expect(pipeline[1]?.hasOutput).toBe(false) + expect(pipeline[1]?.output).toBeUndefined() }) it('aborts pending schema output before a dynamic predicate skips the validator', async () => { const formApi = getForm({ name: 'stale' }) let shouldRunOnSubmit = true let resolveSchema!: (result: { value: { name: string } }) => void - const schemaResult = new Promise<{ value: { name: string } }>( - (resolve) => { - resolveSchema = resolve - }, - ) + const output = new Promise<{ value: { name: string } }>((resolve) => { + resolveSchema = resolve + }) const schema = { '~standard': { version: 1, vendor: 'test', - validate: () => schemaResult, + validate: () => output, }, } satisfies StandardSchemaV1<{ name: string }, { name: string }> const { runWithContext, pipeline } = getPipeline(formApi, [ @@ -1086,8 +1084,8 @@ describe('runFormValidatorPipeline', () => { resolveSchema({ value: { name: 'stale' } }) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(pipeline[0]?.hasSchemaOutput).toBe(false) - expect(pipeline[0]?.schemaOutput).toBeUndefined() + expect(pipeline[0]?.hasOutput).toBe(false) + expect(pipeline[0]?.output).toBeUndefined() }) it('should validate form with a failing zod schema', async () => { @@ -1110,7 +1108,7 @@ describe('runFormValidatorPipeline', () => { expect(results).toEqual([ getFieldErrorMatcher({ name: ['Name is required'] }), ]) - expect(results[0]?.schemaResult).toBeNull() + expect(results[0]?.output).toBeNull() }) it('should validate form with multiple errors from zod schema', async () => { diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index dfc5c409a7..fd5f630260 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -29,8 +29,8 @@ import type { ToFieldError, ToFormErrorTypes, ToFormGroupErrorTypes, - ToFormGroupSchemaOutputs, - ToFormSchemaOutputs, + ToFormGroupOutputs, + ToFormOutputs, ValidationErrorMap, ValidationIssue, } from '../src' @@ -117,9 +117,11 @@ describe('formOptions', () => { const options = formOptions.strictSchema(schema, { defaultValues: { age: '' }, validators: [{ run: schema, triggers: ['change'] }], - onSubmit: ({ value, schemaOutputs }) => { + onSubmit: ({ value, validatorOutputs }) => { expectTypeOf(value).toEqualTypeOf<{ age: string }>() - expectTypeOf(schemaOutputs).toEqualTypeOf() + expectTypeOf(validatorOutputs).toEqualTypeOf< + readonly [{ age: number }] + >() }, }) @@ -158,9 +160,9 @@ describe('formOptions', () => { triggers: ['blur'], }, ], - onSubmit: ({ value, schemaOutputs }) => { + onSubmit: ({ value, validatorOutputs }) => { expectTypeOf(value).toEqualTypeOf() - expectTypeOf(schemaOutputs).toEqualTypeOf< + expectTypeOf(validatorOutputs).toEqualTypeOf< readonly [undefined, { name: string; age: number }, undefined] >() }, @@ -183,8 +185,8 @@ describe('formOptions', () => { triggers: ['change'], }, ], - onSubmit: ({ schemaOutputs }) => { - expectTypeOf(schemaOutputs).toEqualTypeOf() + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf() }, }) @@ -805,8 +807,10 @@ describe('FormErrors', () => { const form = new InternalFormApi({ defaultValues: { name: '' }, validators: [withRequiredValue(z.object({ name: z.string() }))], - onSubmit: ({ schemaOutputs }) => { - expectTypeOf(schemaOutputs).toEqualTypeOf() + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< + readonly [{ name: string }] + >() }, }) @@ -866,8 +870,8 @@ describe('FormErrors', () => { const form = new InternalFormApi({ defaultValues: { name: '' }, validators, - onSubmit: ({ schemaOutputs }) => { - expectTypeOf(schemaOutputs).toEqualTypeOf< + onSubmit: ({ validatorOutputs }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< readonly [{ name: string }, undefined] >() }, @@ -1514,7 +1518,7 @@ describe('validator type transforms', () => { }, ]) - type Outputs = ToFormSchemaOutputs + type Outputs = ToFormOutputs type Errors = ToFormErrorTypes expectTypeOf().toEqualTypeOf() @@ -1554,7 +1558,7 @@ describe('validator type transforms', () => { }, ]) - type Outputs = ToFormSchemaOutputs + type Outputs = ToFormOutputs expectTypeOf().toEqualTypeOf< readonly [ @@ -1616,7 +1620,7 @@ describe('validator type transforms', () => { ]) type Output = { name: string } - type Outputs = ToFormSchemaOutputs + type Outputs = ToFormOutputs type Expected = readonly [ // An omitted runOnSubmit defaults to true, so the output is guaranteed. Output, @@ -1692,7 +1696,7 @@ describe('validator type transforms', () => { ]) type ErrorTypes = ToFormGroupErrorTypes - type Outputs = ToFormGroupSchemaOutputs + type Outputs = ToFormGroupOutputs expectTypeOf().toEqualTypeOf< FormErrorTypes< @@ -1713,12 +1717,10 @@ describe('validator type transforms', () => { expectTypeOf>().toEqualTypeOf< FormErrorTypes >() - expectTypeOf>().toEqualTypeOf< - Array - >() + expectTypeOf>().toEqualTypeOf>() expectTypeOf>().toEqualTypeOf< FormErrorTypes >() - expectTypeOf>().toEqualTypeOf<[]>() + expectTypeOf>().toEqualTypeOf<[]>() }) }) diff --git a/packages/preact-form/tests/FormGroup.test-d.tsx b/packages/preact-form/tests/FormGroup.test-d.tsx index 9cf6f7d898..a7d27188d3 100644 --- a/packages/preact-form/tests/FormGroup.test-d.tsx +++ b/packages/preact-form/tests/FormGroup.test-d.tsx @@ -170,12 +170,12 @@ function FormGroupSubmitTypes() { triggers: [], }, ]} - onSubmit={({ schemaOutputs, groupApi }) => { - expectTypeOf(schemaOutputs['length']).toEqualTypeOf<2>() - expectTypeOf(schemaOutputs[0]).toEqualTypeOf<{ + onSubmit={({ validatorOutputs, groupApi }) => { + expectTypeOf(validatorOutputs['length']).toEqualTypeOf<2>() + expectTypeOf(validatorOutputs[0]).toEqualTypeOf<{ nameLength: number }>() - expectTypeOf(schemaOutputs[1]).toEqualTypeOf() + expectTypeOf(validatorOutputs[1]).toEqualTypeOf() expectTypeOf>().toEqualTypeOf< FormErrorTypes >() diff --git a/packages/preact-form/tests/submit-return.test-d.tsx b/packages/preact-form/tests/submit-return.test-d.tsx index 46e3c85aef..52b1e9d2c5 100644 --- a/packages/preact-form/tests/submit-return.test-d.tsx +++ b/packages/preact-form/tests/submit-return.test-d.tsx @@ -20,10 +20,15 @@ describe('submit return', () => { run: emailSchema, triggers: [], }, + { + run: ({ value, createOutput }) => + createOutput({ normalizedEmail: value.email.trim() }), + triggers: [], + }, ], - onSubmit: ({ schemaOutputs, createValidationError }) => { - expectTypeOf(schemaOutputs).toEqualTypeOf< - readonly [{ email: string }] + onSubmit: ({ validatorOutputs, createValidationError }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< + readonly [{ email: string }, { normalizedEmail: string }] >() return createValidationError({ diff --git a/packages/react-form/skills/choosing-form-options-modes/SKILL.md b/packages/react-form/skills/choosing-form-options-modes/SKILL.md index 5104a3708a..7ddc74e02b 100644 --- a/packages/react-form/skills/choosing-form-options-modes/SKILL.md +++ b/packages/react-form/skills/choosing-form-options-modes/SKILL.md @@ -5,7 +5,7 @@ description: > formOptions.looseSchema, appFormOptions, appFormOptions.strictSchema, or appFormOptions.looseSchema. Covers defaultValues-first inference, schema-source typing, Standard Schema validators, pipeline schemas, ruleset schemas, nullish - editable defaults, and schemaOutputs in onSubmit. + editable defaults, and validatorOutputs in onSubmit. metadata: type: framework library: '@tanstack/react-form' @@ -96,8 +96,8 @@ export const strictOptions = formOptions.strictSchema({ name: '', }, validators: [{ run: schema, triggers: ['change'] }], - onSubmit: ({ schemaOutputs }) => { - const parsed = schemaOutputs[0] + onSubmit: ({ validatorOutputs }) => { + const parsed = validatorOutputs[0] console.log(parsed.name) }, }) @@ -120,8 +120,8 @@ export const looseOptions = formOptions.looseSchema({ startDate: null, }, validators: [{ run: schema, triggers: ['blur'] }], - onSubmit: ({ schemaOutputs }) => { - const parsed = schemaOutputs[0] + onSubmit: ({ validatorOutputs }) => { + const parsed = validatorOutputs[0] console.log(parsed.startDate.toISOString()) }, }) @@ -223,13 +223,13 @@ onSubmit: ({ value }) => { Correct: ```ts -onSubmit: ({ schemaOutputs }) => { - const parsed = schemaOutputs[0] +onSubmit: ({ validatorOutputs }) => { + const parsed = validatorOutputs[0] console.log(parsed.nameLength) } ``` -Raw `value` remains form state; parsed Standard Schema output is exposed through `schemaOutputs`. +Raw `value` remains form state; parsed Standard Schema output is exposed through `validatorOutputs`. Source: TanStack/form-v2:packages/form-core/tests/validation.test.ts diff --git a/packages/react-form/skills/choosing-form-options-modes/references/schema-option-modes.md b/packages/react-form/skills/choosing-form-options-modes/references/schema-option-modes.md index 7973bba647..a484246963 100644 --- a/packages/react-form/skills/choosing-form-options-modes/references/schema-option-modes.md +++ b/packages/react-form/skills/choosing-form-options-modes/references/schema-option-modes.md @@ -2,9 +2,9 @@ Default `formOptions` is defaultValues-first. The default values are taken at face value and validator callbacks infer from that shape. -`strictSchema` is schema-source pipeline mode. Defaults must align with the schema input type, and submit code should read parsed output from `schemaOutputs`. +`strictSchema` is schema-source pipeline mode. Defaults must align with the schema input type, and submit code should read parsed output from `validatorOutputs`. -`looseSchema` is schema-source ruleset mode with editable defaults. Defaults must match the schema shape but may include `null` or `undefined` where editing requires it. Submit code still reads parsed output from `schemaOutputs`. +`looseSchema` is schema-source ruleset mode with editable defaults. Defaults must match the schema shape but may include `null` or `undefined` where editing requires it. Submit code still reads parsed output from `validatorOutputs`. Callbacks in schema modes may not infer the expected value type unless there is a schema in the validator array. diff --git a/packages/react-form/skills/schema-driven-forms/SKILL.md b/packages/react-form/skills/schema-driven-forms/SKILL.md index 175bae98f5..9df0b55af3 100644 --- a/packages/react-form/skills/schema-driven-forms/SKILL.md +++ b/packages/react-form/skills/schema-driven-forms/SKILL.md @@ -2,7 +2,7 @@ name: schema-driven-forms description: > Use when wiring Standard Schema-compatible schemas into TanStack React Form v2 - validators, schemaOutputs, parseIssues, createValidationError, field errors, + validators, validatorOutputs, parseIssues, createValidationError, field errors, nested issue paths, runOnSubmit behavior, and typed submit output. Load after choosing form option mode for schema-driven React forms. metadata: @@ -24,7 +24,7 @@ This skill builds on `choosing-form-options-modes`. Read it first to pick defaul # TanStack React Form - Schema Driven Forms -Put Standard Schema-compatible schemas in validators. Read parsed schema output from `schemaOutputs`; keep raw `value` as editable form state. +Put Standard Schema-compatible schemas in validators. Read parsed schema output from `validatorOutputs`; keep raw `value` as editable form state. ## Setup @@ -43,8 +43,8 @@ const profileOptions = formOptions.strictSchema({ email: '', }, validators: [{ run: profileSchema, triggers: ['blur'] }], - onSubmit: ({ schemaOutputs }) => { - const parsedProfile = schemaOutputs[0] + onSubmit: ({ validatorOutputs }) => { + const parsedProfile = validatorOutputs[0] console.log(parsedProfile.email) }, }) @@ -141,8 +141,8 @@ onSubmit: ({ value }) => { Correct: ```ts -onSubmit: ({ schemaOutputs }) => { - const parsed = schemaOutputs[0] +onSubmit: ({ validatorOutputs }) => { + const parsed = validatorOutputs[0] console.log(parsed.nameLength) } ``` diff --git a/packages/react-form/skills/schema-driven-forms/references/schema-validation-and-submit-output.md b/packages/react-form/skills/schema-driven-forms/references/schema-validation-and-submit-output.md index cc768f015b..2495499b1c 100644 --- a/packages/react-form/skills/schema-driven-forms/references/schema-validation-and-submit-output.md +++ b/packages/react-form/skills/schema-driven-forms/references/schema-validation-and-submit-output.md @@ -2,7 +2,7 @@ Schema validators can run alongside callback validators in the `validators` array. -`schemaOutputs` is ordered by validator position. If a validator does not run on submit, its schema output slot can be absent or undefined. +`validatorOutputs` is ordered by validator position. If a validator does not run on submit, its output slot is undefined. Custom form and group validators can return `createOutput(data)` from their context to populate their slot too. Use `createValidationError` for explicit form or field validation errors from submit code. diff --git a/packages/react-form/skills/type-error-debugging/SKILL.md b/packages/react-form/skills/type-error-debugging/SKILL.md index 61b03e8d11..f6057d3952 100644 --- a/packages/react-form/skills/type-error-debugging/SKILL.md +++ b/packages/react-form/skills/type-error-debugging/SKILL.md @@ -93,7 +93,7 @@ const options = formOptions.looseSchema({ }) ``` -For schema errors, check mode, default value shape, validator presence, and whether submit reads `schemaOutputs`. +For schema errors, check mode, default value shape, validator presence, and whether submit reads `validatorOutputs`. ## Common Mistakes diff --git a/packages/react-form/skills/type-error-debugging/references/type-error-boundaries.md b/packages/react-form/skills/type-error-debugging/references/type-error-boundaries.md index 37c8742246..06c88d227c 100644 --- a/packages/react-form/skills/type-error-debugging/references/type-error-boundaries.md +++ b/packages/react-form/skills/type-error-debugging/references/type-error-boundaries.md @@ -2,7 +2,7 @@ Schema option mode: check whether defaults-first, strict schema, or loose schema matches the intended source of truth. -Schema output: raw `value` is editable form state; parsed output is in `schemaOutputs`. +Schema output: raw `value` is editable form state; parsed output is in `validatorOutputs`. Submit return: reusable child form props can become too specific if shared options include an `onSubmit` type. diff --git a/packages/react-form/tests/FormGroup.test-d.tsx b/packages/react-form/tests/FormGroup.test-d.tsx index 8ca5aaaf82..01b14972d8 100644 --- a/packages/react-form/tests/FormGroup.test-d.tsx +++ b/packages/react-form/tests/FormGroup.test-d.tsx @@ -170,12 +170,12 @@ function FormGroupSubmitTypes() { triggers: [], }, ]} - onSubmit={({ schemaOutputs, groupApi }) => { - expectTypeOf(schemaOutputs['length']).toEqualTypeOf<2>() - expectTypeOf(schemaOutputs[0]).toEqualTypeOf<{ + onSubmit={({ validatorOutputs, groupApi }) => { + expectTypeOf(validatorOutputs['length']).toEqualTypeOf<2>() + expectTypeOf(validatorOutputs[0]).toEqualTypeOf<{ nameLength: number }>() - expectTypeOf(schemaOutputs[1]).toEqualTypeOf() + expectTypeOf(validatorOutputs[1]).toEqualTypeOf() expectTypeOf>().toEqualTypeOf< FormErrorTypes >() diff --git a/packages/react-form/tests/submit-return.test-d.tsx b/packages/react-form/tests/submit-return.test-d.tsx index dda81ff882..4d8ebf38a4 100644 --- a/packages/react-form/tests/submit-return.test-d.tsx +++ b/packages/react-form/tests/submit-return.test-d.tsx @@ -20,10 +20,15 @@ describe('submit return', () => { run: emailSchema, triggers: [], }, + { + run: ({ value, createOutput }) => + createOutput({ normalizedEmail: value.email.trim() }), + triggers: [], + }, ], - onSubmit: ({ schemaOutputs, createValidationError }) => { - expectTypeOf(schemaOutputs).toEqualTypeOf< - readonly [{ email: string }] + onSubmit: ({ validatorOutputs, createValidationError }) => { + expectTypeOf(validatorOutputs).toEqualTypeOf< + readonly [{ email: string }, { normalizedEmail: string }] >() return createValidationError({ From a94257831617f3f329268844a6ad73672aa4eef9 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:42:46 +0200 Subject: [PATCH 2/5] refactor: make standard schema use brand too --- packages/form-core/src/standardSchema.lib.ts | 31 +++------- .../form-core/src/validation/execution.lib.ts | 8 +-- .../form-core/src/validation/mount.lib.ts | 36 +++++------- .../form-core/tests/standardSchema.spec.ts | 57 ++++++++----------- .../tests/validation-output.test-d.ts | 14 ++++- 5 files changed, 65 insertions(+), 81 deletions(-) diff --git a/packages/form-core/src/standardSchema.lib.ts b/packages/form-core/src/standardSchema.lib.ts index 3c066332fd..0e7e7af1d5 100644 --- a/packages/form-core/src/standardSchema.lib.ts +++ b/packages/form-core/src/standardSchema.lib.ts @@ -1,4 +1,5 @@ import { isNil } from './utils.lib' +import { createOutput } from './validationOutput.lib' import type { StandardSchemaV1, StandardSchemaV1Issue, @@ -6,9 +7,9 @@ import type { import type { FieldValidatorFn, FormGroupValidatorFn, - FormValidateResult, FormValidatorFn, ParsedStandardSchemaIssues, + ValidationOutput, } from './validation.public' export function prefixSchemaToErrors( @@ -77,31 +78,17 @@ export function parseStandardSchema( schema: StandardSchemaV1, value: any, scope: 'form' | 'field', -): Promise<{ - result: FormValidateResult - output: TOutput | null - hasOutput: boolean -}> { +): Promise< + | ValidationOutput + | ParsedStandardSchemaIssues + | Array +> { return Promise.resolve(schema['~standard'].validate(value)).then((result) => { if (!result.issues) { - return { - result: null, - output: result.value, - hasOutput: true, - } + return createOutput(result.value) } - const validationResult = parseStandardSchemaIssues( - result.issues, - value, - scope, - ) - - return { - result: validationResult, - output: null, - hasOutput: false, - } + return parseStandardSchemaIssues(result.issues, value, scope) }) } diff --git a/packages/form-core/src/validation/execution.lib.ts b/packages/form-core/src/validation/execution.lib.ts index 4b6160a36d..a7e1ba92f2 100644 --- a/packages/form-core/src/validation/execution.lib.ts +++ b/packages/form-core/src/validation/execution.lib.ts @@ -251,11 +251,11 @@ export async function executeValidator( context: AnyValidatorContext, scope: 'field' | 'form', ): Promise> { - if (isStandardSchema(validator.run)) { - return parseStandardSchema(validator.run, context.value, scope) as never - } + const result = isStandardSchema(validator.run) + ? await parseStandardSchema(validator.run, context.value, scope) + : await validator.run(context) - return normalizeValidatorResult((await validator.run(context)) as TResult) + return normalizeValidatorResult(result as TResult) } /** Separates explicitly branded outputs from ordinary validation returns. */ diff --git a/packages/form-core/src/validation/mount.lib.ts b/packages/form-core/src/validation/mount.lib.ts index 79447996c6..b914809e5f 100644 --- a/packages/form-core/src/validation/mount.lib.ts +++ b/packages/form-core/src/validation/mount.lib.ts @@ -21,15 +21,13 @@ import type { FormGroupValidateResult, FormValidateResult, } from '../validation.public' -import type { AnyValidatorContext, ValidateResult } from './execution.lib' +import type { + AnyValidatorContext, + ValidateResult, + ValidatorExecutionResult, +} from './execution.lib' import type { PipelineResult } from './pipeline.lib' -type MountValidationExecutionResult = { - result: TResult - output: any | null - hasOutput: boolean -} - interface FormMountValidatorPipelineArgs { pipeline: ReadonlyArray formApi: InternalFormApi @@ -53,12 +51,8 @@ interface MountValidatorPipelineArgs { */ function createEmptyMountValidationResult< TResult extends ValidateResult, ->(): MountValidationExecutionResult { - return { - result: null as TResult, - output: null, - hasOutput: false, - } +>(): ValidatorExecutionResult { + return normalizeValidatorResult(null as TResult) } /** @@ -69,7 +63,7 @@ function createEmptyMountValidationResult< */ function processMountValidationExecutionResult( validatorInstance: AnyInternalValidatorInstance, - executionResult: MountValidationExecutionResult, + executionResult: ValidatorExecutionResult, onResult?: (result: PipelineResult) => void, ): boolean { const result: PipelineResult = { @@ -95,8 +89,8 @@ function executeMountValidator( scope: 'field' | 'form', validatorInstance: AnyInternalValidatorInstance, ): - | MountValidationExecutionResult - | PromiseLike> { + | ValidatorExecutionResult + | PromiseLike> { const validator = validatorInstance.definition const { signal, cleanup } = createValidatorAbortContext(validatorInstance, { cancelDebouncer: true, @@ -112,22 +106,20 @@ function executeMountValidator( return createEmptyMountValidationResult() } - return result + return normalizeValidatorResult(result as TResult) }) .catch((error) => { console.error(error) return createEmptyMountValidationResult() }) - .finally(cleanup) as unknown as PromiseLike< - MountValidationExecutionResult - > + .finally(cleanup) } const result = validator.run(context) if (isPromiseLike(result)) { return Promise.resolve(result) - .then((asyncResult): MountValidationExecutionResult => { + .then((asyncResult): ValidatorExecutionResult => { if (signal.aborted) { return createEmptyMountValidationResult() } @@ -163,7 +155,7 @@ async function continueMountValidationFromAsyncResult< getContext: MountValidatorPipelineArgs['getContext'], scope: 'field' | 'form', startIndex: number, - firstResult: PromiseLike>, + firstResult: PromiseLike>, hasFailedBefore: boolean, onResult?: (result: PipelineResult) => void, ): Promise { diff --git a/packages/form-core/tests/standardSchema.spec.ts b/packages/form-core/tests/standardSchema.spec.ts index 85210038eb..870e8bb769 100644 --- a/packages/form-core/tests/standardSchema.spec.ts +++ b/packages/form-core/tests/standardSchema.spec.ts @@ -4,6 +4,7 @@ import { parseStandardSchema, prefixSchemaToErrors, } from '../src/standardSchema.lib' +import { VALIDATION_OUTPUT } from '../src/validationOutput.lib' import type { StandardSchemaV1, StandardSchemaV1Issue, @@ -127,17 +128,15 @@ describe('standard schema validation', () => { }) describe('parseStandardSchema', () => { - it('returns schema output for successful validation', async () => { + it('returns branded schema output for successful validation', async () => { const schema = getSchema((value) => ({ value: { parsed: String(value).toUpperCase() }, })) - await expect( - parseStandardSchema(schema, 'value', 'form'), - ).resolves.toEqual({ - result: null, - output: { parsed: 'VALUE' }, - hasOutput: true, + const result = await parseStandardSchema(schema, 'value', 'form') + + expect(result).toEqual({ + [VALIDATION_OUTPUT]: { parsed: 'VALUE' }, }) }) @@ -147,11 +146,9 @@ describe('standard schema validation', () => { ] satisfies Array const schema = getSchema(() => ({ issues })) - await expect(parseStandardSchema(schema, '', 'field')).resolves.toEqual({ - result: issues, - output: null, - hasOutput: false, - }) + const result = await parseStandardSchema(schema, '', 'field') + + expect(result).toEqual(issues) }) it('returns prefixed field errors for form scope', async () => { @@ -160,17 +157,17 @@ describe('standard schema validation', () => { ] satisfies Array const schema = getSchema(() => Promise.resolve({ issues })) - await expect( - parseStandardSchema(schema, { users: [{ name: '' }] }, 'form'), - ).resolves.toEqual({ - result: { - form: issues, - fields: { - 'users[0].name': issues, - }, + const result = await parseStandardSchema( + schema, + { users: [{ name: '' }] }, + 'form', + ) + + expect(result).toEqual({ + form: issues, + fields: { + 'users[0].name': issues, }, - output: null, - hasOutput: false, }) }) @@ -182,17 +179,13 @@ describe('standard schema validation', () => { ] satisfies Array const schema = getSchema(() => ({ issues })) - await expect( - parseStandardSchema(schema, { name: '' }, 'form'), - ).resolves.toEqual({ - result: { - form: issues, - fields: { - name: [issues[2]], - }, + const result = await parseStandardSchema(schema, { name: '' }, 'form') + + expect(result).toEqual({ + form: issues, + fields: { + name: [issues[2]], }, - output: null, - hasOutput: false, }) }) diff --git a/packages/form-core/tests/validation-output.test-d.ts b/packages/form-core/tests/validation-output.test-d.ts index 8c3e80f214..1f743f8e39 100644 --- a/packages/form-core/tests/validation-output.test-d.ts +++ b/packages/form-core/tests/validation-output.test-d.ts @@ -1,7 +1,7 @@ import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' import { formOptions } from '../src' -import { validateServerValues } from '../src/internals' +import { parseStandardSchema, validateServerValues } from '../src/internals' import type { FieldValidatorContext, FieldValidators, @@ -31,6 +31,18 @@ function defineGroupValidators>( } describe('validator output types', () => { + it('retains the schema output type in parsed output wrappers', () => { + const result = parseStandardSchema( + z.object({ name: z.string() }), + { name: 'Tony' }, + 'form', + ) + + expectTypeOf< + Extract, ValidationOutput> + >().toEqualTypeOf>() + }) + it('infers mixed schema and sync/async function outputs without raw error returns', () => { formOptions({ defaultValues: { name: '' }, From 0c9a18ffcce8236a2ffa033bbcfbd43a85c2a368 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:59:13 +0200 Subject: [PATCH 3/5] refactor: make createSubmitError also wrapped --- .../form-core/src/FormApi/FormApi.public.ts | 14 +++++------ .../form-core/src/FormApi/handleSubmit.lib.ts | 24 ++++++------------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/packages/form-core/src/FormApi/FormApi.public.ts b/packages/form-core/src/FormApi/FormApi.public.ts index f6bb373629..f7824c0907 100644 --- a/packages/form-core/src/FormApi/FormApi.public.ts +++ b/packages/form-core/src/FormApi/FormApi.public.ts @@ -15,11 +15,10 @@ 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' - -declare const onSubmitErrorBrand: unique symbol +import type { SUBMIT_ERROR } from './handleSubmit.lib' /** - * A validation error marked for processing as an `onSubmit` failure. + * 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 @@ -43,18 +42,17 @@ declare const onSubmitErrorBrand: unique symbol */ export type OnSubmitError< TFormValidationError extends FormValidationError, -> = TFormValidationError & { - /** Internal brand used to identify submit errors. Do not access directly. */ - [onSubmitErrorBrand]: true +> = { + readonly [SUBMIT_ERROR]: TFormValidationError } /** - * Marks a validation error for processing as an `onSubmit` failure. + * 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 mark. + * @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. */ diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index ba32d34bdb..01aabfb041 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -1,7 +1,6 @@ import { batch } from '@tanstack/store' import { isErrorResult } from '../validation' import { parseStandardSchemaIssues } from '../standardSchema.lib' -import { isNotNil } from '../utils.lib' import type { InternalFormApi } from './FormApi.lib' import type { FormValidateResult, @@ -14,12 +13,12 @@ import type { ParseSubmitIssuesFn, } from './FormApi.public' -const SUBMIT_ERROR = Symbol('SUBMIT_ERROR') +export const SUBMIT_ERROR = Symbol('SUBMIT_ERROR') function isSubmitError( value: unknown, -): value is FormValidationError { - return isNotNil(value) && Boolean((value as any)[SUBMIT_ERROR]) +): value is OnSubmitError> { + return typeof value === 'object' && value !== null && SUBMIT_ERROR in value } const createValidationError: CreateValidationErrorFn = < @@ -33,17 +32,7 @@ const createValidationError: CreateValidationErrorFn = < function createSubmitError>( error: TError, ): OnSubmitError { - let output: OnSubmitError - if (typeof error === 'string') { - // strings can't retain symbols, so we gotta normalize early - output = { message: error } as any - } else { - output = error as any - } - const runtimeOutput = output as any - runtimeOutput[SUBMIT_ERROR] = true - - return output + return { [SUBMIT_ERROR]: error } } function createParseIssues( @@ -177,8 +166,9 @@ export async function runSubmissionProcess( // Store onSubmit errors separately from installed validator instances. if (isSubmitError(maybeError)) { - form._processSubmitValidationResult(maybeError, 'submit') - submissionData.submitError = maybeError + const error = maybeError[SUBMIT_ERROR] + form._processSubmitValidationResult(error, 'submit') + submissionData.submitError = error } else { form._processSubmitValidationResult(null, 'submit') } From facb61877f91437bbe5d843b01e76271d9037b46 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:02:36 +0200 Subject: [PATCH 4/5] fix: resolve typo in skill spec --- _artifacts/domain_map.yaml | 18 +++++++++--------- _artifacts/skill_spec.md | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml index 8f978bd158..acee7ce01d 100644 --- a/_artifacts/domain_map.yaml +++ b/_artifacts/domain_map.yaml @@ -44,7 +44,7 @@ maintainer_guidance: properties: - 'defaultValues must align with the schema input type and should produce type errors when they do not.' - 'Callbacks may not have useful inference unless a schema is present in the validator array.' - - 'onSubmit should read the transform output from outputs instead of raw value.' + - 'onSubmit should read the transform output from validatorOutputs instead of raw value.' loose_schema: summary: 'Schema-source ruleset mode with editable defaults.' use_when: @@ -54,7 +54,7 @@ maintainer_guidance: - 'The input is looser while the parsed submit output is still the schema output type.' - 'defaultValues must still align with the schema shape, but properties may also be null or undefined where editing requires it.' - 'Callbacks may not have useful inference unless a schema is present in the validator array.' - - 'onSubmit should read the transform output from outputs instead of raw value.' + - 'onSubmit should read the transform output from validatorOutputs instead of raw value.' schema_ecosystem: standard_schema: role: 'Most common integration shape.' @@ -247,7 +247,7 @@ domains: - '@tanstack/form-core' core_questions: - 'Is the schema a pipeline from input A to output B, or a ruleset for confirming B?' - - 'Should submit logic read raw value or outputs?' + - 'Should submit logic read raw value or validatorOutputs?' - 'Do defaults need to be nullable or maybe undefined while editing?' related_skills: - choosing-form-options-modes @@ -492,7 +492,7 @@ skill_candidates: - 'looseSchema uses the schema as a ruleset while allowing editable default values that include null or undefined where needed.' - 'looseSchema defaultValues must still align with the schema shape, but the input can be looser while output remains schema output.' - 'In schema modes, callbacks may not infer usefully unless a schema is present in the validator array.' - - 'In schema modes, onSubmit should read outputs instead of raw value.' + - 'In schema modes, onSubmit should read validatorOutputs instead of raw value.' - 'Use Standard Schema-capable libraries as heuristic examples only: Zod generally leans ruleset/firewall, Valibot generally leans pipeline, ArkType is mixed, and Effect Schema often leans pipeline/codec.' failure_modes: - name: expecting_option_helper_runtime_behavior @@ -527,13 +527,13 @@ skill_candidates: - packages/form-core/tests/validation.test-d.ts - name: loose_pipeline_schema_loses_transform_intent symptom: 'Submit logic treats raw form value as if it were the transformed schema output.' - correction: 'Use strictSchema for pipeline schemas and read the transformed value from outputs.' + correction: 'Use strictSchema for pipeline schemas and read the transformed value from validatorOutputs.' evidence: - packages/form-core/tests/validation.test.ts - packages/form-core/tests/validation.test-d.ts - name: on_submit_reads_value_in_schema_mode symptom: 'onSubmit reads value even though strictSchema or looseSchema is being used.' - correction: 'Read the relevant outputs entry, because both schema modes preserve raw form value separately from parsed output.' + correction: 'Read the relevant validatorOutputs entry, because both schema modes preserve raw form value separately from parsed output.' evidence: - packages/form-core/tests/validation.test.ts - packages/form-core/src/FormApi/FormApi.public.ts @@ -551,7 +551,7 @@ skill_candidates: purpose: 'Help users turn Standard Schema-compatible schemas into form validators, field errors, typed submit output, and realistic examples.' entrypoints: - 'validators' - - 'outputs' + - 'FormSubmitContext.validatorOutputs' - 'parseIssues' - 'createValidationError' - 'Standard Schema validators' @@ -566,13 +566,13 @@ skill_candidates: teaches: - 'Use Standard Schema-compatible validators in form or field validators.' - 'Extract reusable validator trigger configs with createValidator or createValidators when the policy is complex enough to deserve a semantic name.' - - 'Read parsed schema output from onSubmit context.outputs.' + - 'Read parsed schema output from onSubmit context.validatorOutputs.' - 'Return validation errors as values through createValidationError or parseIssues; do not throw them.' - 'Let Standard Schema issue paths route field errors instead of hand-building brittle path maps.' failure_modes: - name: assuming_schema_output_replaces_value symptom: 'onSubmit reads value expecting transformed/coerced data.' - correction: 'Use outputs for parsed schema output; value remains form state.' + correction: 'Use validatorOutputs for parsed schema output; value remains form state.' evidence: - packages/form-core/src/FormApi/FormApi.public.ts - packages/form-core/tests/validation.test.ts diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md index 1a39ded0f5..90746a5ea0 100644 --- a/_artifacts/skill_spec.md +++ b/_artifacts/skill_spec.md @@ -173,7 +173,7 @@ Core guidance: - Use `looseSchema` when the schema is a ruleset for confirming valid output, but editable defaults need nullable or undefined values, such as a date field that starts as `null`. - In `looseSchema`, default values must still align with the schema shape, but properties may also be `null` or `undefined` where editing requires it. - In both schema modes, callbacks may not have useful inference unless there is a schema in the validator array for the helper to infer from. -- In both schema modes, `onSubmit` should read the parsed transform output from `outputs` rather than raw `value`. +- In both schema modes, `onSubmit` should read the parsed transform output from `validatorOutputs` rather than raw `value`. - Treat pipeline-vs-ruleset as TanStack Form guidance, not a Standard Schema concept. Standard Schema only gives the shared validation/typing interface. - Explain the mental model as: is this schema a pipeline of constraints/transforms, or a firewall that only lets valid values through? - List current Standard Schema-capable libraries as examples with a general leaning, not as hard categories. @@ -190,7 +190,7 @@ Important failure modes: - Using `looseSchema` but providing default values that no longer match the schema shape at all. - Using schema modes without putting a schema in the validator array, then expecting callbacks to infer from that schema. - Using loose schema mode for a true pipeline and then reading raw `value` as if it were parsed output. -- Reading `value` in `onSubmit` for schema-mode forms instead of reading the corresponding `outputs` entry. +- Reading `value` in `onSubmit` for schema-mode forms instead of reading the corresponding `validatorOutputs` entry. - Treating all Zod `input` and `output` differences as a real pipeline decision. Source trail: @@ -214,7 +214,7 @@ Purpose: Help users take a Standard Schema-compatible schema and wire it into va Primary APIs: - `validators` -- `outputs` +- `FormSubmitContext.validatorOutputs` - `parseIssues` - `createValidationError` @@ -222,7 +222,7 @@ Core guidance: - Configure schema validation through validators. - Extract reusable validator trigger configs with `createValidator` or `createValidators` when the behavior is complex enough to need a semantic name. -- Read parsed schema output from `outputs`; raw `value` remains form state. +- Read parsed schema output from `onSubmit` context's `validatorOutputs`; raw `value` remains form state. - Route submit validation errors as returned values through `createValidationError` or `parseIssues`; do not throw them. - Let Standard Schema issue paths route nested and array field errors when possible. - Keep UI metadata explicit or schema-library-specific; Standard Schema does not standardize it. From ab5b00d0d951b51542a91435b66cdeb7f8e2310e Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:35:05 +0200 Subject: [PATCH 5/5] fix: add temporarily bad code, fix in next PR --- .../form-core/src/FormApi/handleSubmit.lib.ts | 15 +++++++++++++-- .../tests/FormApi/submission-handling.spec.ts | 4 +++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/form-core/src/FormApi/handleSubmit.lib.ts b/packages/form-core/src/FormApi/handleSubmit.lib.ts index 01aabfb041..634755d402 100644 --- a/packages/form-core/src/FormApi/handleSubmit.lib.ts +++ b/packages/form-core/src/FormApi/handleSubmit.lib.ts @@ -1,5 +1,9 @@ import { batch } from '@tanstack/store' -import { isErrorResult } from '../validation' +import { + isErrorResult, + isValidationErrorMap, + parseValidationResult, +} from '../validation' import { parseStandardSchemaIssues } from '../standardSchema.lib' import type { InternalFormApi } from './FormApi.lib' import type { @@ -141,7 +145,14 @@ export async function runSubmissionProcess( return [] } - return errorResults + // TODO ew. Resolve this differently, this is really bad. + return errorResults.flatMap>((error) => { + const { self, subfields } = parseValidationResult(error) + if (isValidationErrorMap(error)) { + return [{ ...error, form: self ?? undefined, fields: subfields ?? {} }] + } + return self ?? [] + }) } if (submissionData.hasFailed) { diff --git a/packages/form-core/tests/FormApi/submission-handling.spec.ts b/packages/form-core/tests/FormApi/submission-handling.spec.ts index a756142fdb..b66e2bd3e0 100644 --- a/packages/form-core/tests/FormApi/submission-handling.spec.ts +++ b/packages/form-core/tests/FormApi/submission-handling.spec.ts @@ -167,7 +167,9 @@ describe('form - submission handling', () => { expect(form.state.isSubmitSuccessful).toBe(false) finishInvalidSubmit() - await expect(submitPromise).resolves.toEqual(['Submit error']) + await expect(submitPromise).resolves.toEqual([ + { message: 'Submit error' }, + ]) expect(form.state.isSubmitting).toBe(false) })