From 72c0b37c9b033f6e3e4e082b5c59935d40680e32 Mon Sep 17 00:00:00 2001 From: Matthew Gamble Date: Thu, 16 Apr 2026 12:30:54 +1000 Subject: [PATCH 1/2] fix: ensure fieldName is passed to custom validation logic functions This was always part of the types for custom validation functions, it just wasn't wired up. This update is pretty straightforward - it just wires up the field, and adds a test to prove that custom field-level dynamic validation is now possible. The example in the test mostly follows a basic version of what's outlined in #1861. Fixes #1861 --- .changeset/whole-views-wear.md | 5 + packages/form-core/src/FieldApi.ts | 4 + packages/form-core/src/ValidationLogic.ts | 2 +- packages/form-core/src/utils.ts | 6 +- .../form-core/tests/DynamicValidation.spec.ts | 170 ++++++++++++++++++ 5 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 .changeset/whole-views-wear.md diff --git a/.changeset/whole-views-wear.md b/.changeset/whole-views-wear.md new file mode 100644 index 0000000000..c08e79d9a7 --- /dev/null +++ b/.changeset/whole-views-wear.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Ensure fieldName is passed to custom validation logic functions diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index a064a83ded..7d4a37b4c7 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1676,6 +1676,7 @@ export class FieldApi< const validates = getSyncValidatorArray(cause, { ...this.options, form: this.form, + fieldName: this.name, validationLogic: this.form.options.validationLogic || defaultValidationLogic, }) @@ -1686,6 +1687,7 @@ export class FieldApi< const fieldValidates = getSyncValidatorArray(cause, { ...field.options, form: field.form, + fieldName: field.name, validationLogic: field.form.options.validationLogic || defaultValidationLogic, }) @@ -1812,6 +1814,7 @@ export class FieldApi< const validates = getAsyncValidatorArray(cause, { ...this.options, form: this.form, + fieldName: this.name, validationLogic: this.form.options.validationLogic || defaultValidationLogic, }) @@ -1825,6 +1828,7 @@ export class FieldApi< const fieldValidates = getAsyncValidatorArray(cause, { ...field.options, form: field.form, + fieldName: field.name, validationLogic: field.form.options.validationLogic || defaultValidationLogic, }) diff --git a/packages/form-core/src/ValidationLogic.ts b/packages/form-core/src/ValidationLogic.ts index e374495284..dbf2818fc5 100644 --- a/packages/form-core/src/ValidationLogic.ts +++ b/packages/form-core/src/ValidationLogic.ts @@ -1,6 +1,6 @@ import type { AnyFormApi, FormValidators } from './FormApi' -interface ValidationLogicValidatorsFn { +export interface ValidationLogicValidatorsFn { // TODO: Type this properly fn: FormValidators< any, diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index 71a6ddb690..7cabb65b5d 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -251,6 +251,7 @@ export function getSyncValidatorArray( options: SyncValidatorArrayPartialOptions & { validationLogic?: any form?: any + fieldName?: string }, ): T extends FieldValidators< any, @@ -300,7 +301,7 @@ export function getSyncValidatorArray( return options.validationLogic({ form: options.form, validators: options.validators, - event: { type: cause, async: false }, + event: { type: cause, fieldName: options.fieldName, async: false }, runValidation, }) } @@ -313,6 +314,7 @@ export function getAsyncValidatorArray( options: AsyncValidatorArrayPartialOptions & { validationLogic?: any form?: any + fieldName?: string }, ): T extends FieldValidators< any, @@ -410,7 +412,7 @@ export function getAsyncValidatorArray( return options.validationLogic({ form: options.form, validators: options.validators, - event: { type: cause, async: true }, + event: { type: cause, fieldName: options.fieldName, async: true }, runValidation, }) } diff --git a/packages/form-core/tests/DynamicValidation.spec.ts b/packages/form-core/tests/DynamicValidation.spec.ts index be0084e992..9310a57e74 100644 --- a/packages/form-core/tests/DynamicValidation.spec.ts +++ b/packages/form-core/tests/DynamicValidation.spec.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { FieldApi, FormApi } from '../src/index' import { defaultValidationLogic, revalidateLogic } from '../src/ValidationLogic' +import { sleep } from './utils' +import type { ValidationLogicFn, ValidationLogicProps, ValidationLogicValidatorsFn } from '../src/ValidationLogic' describe('custom validation', () => { it('should handle default validation logic', async () => { @@ -233,4 +235,172 @@ describe('custom validation', () => { expect(field.state.meta.errorMap.onDynamic).toBe(undefined) }) + + describe('customised field-level validation logic', () => { + const validationLogic: ValidationLogicFn = (props) => { + const validatorNames = Object.keys(props.validators ?? {}) + if (validatorNames.length === 0) { + // No validators is a valid case, just return + return props.runValidation({ + validators: [], + form: props.form, + }) + } + + let validators: ValidationLogicValidatorsFn[] = [] + defaultValidationLogic({ + ...props, + runValidation: (vProps) => { + validators = vProps.validators.slice() as ValidationLogicValidatorsFn[] + } + }) + + let addDynamicValidator = props.event.type === 'submit' + if (!addDynamicValidator) { + const hasFormSubmitted = props.form.state.submissionAttempts > 0 + const modesToWatch: ValidationLogicProps['event']['type'][] = hasFormSubmitted ? ['change'] : (props.event.fieldName ? ( + props.form.state.fieldMeta[props.event.fieldName]?.isBlurred ? ['change', 'blur'] : ['blur'] + ) : ['blur']) + addDynamicValidator = modesToWatch.includes(props.event.type) + } + if (addDynamicValidator) { + validators.push({ + fn: props.event.async + ? props.validators!['onDynamicAsync'] + : props.validators!['onDynamic'], + cause: 'dynamic', + }) + } + + return props.runValidation({ + validators, + form: props.form, + }) + } + + it('should support sync validation', async () => { + const form = new FormApi({ + defaultValues: { + firstName: '', + lastName: '', + }, + validationLogic, + }) + form.mount() + + const fieldFirstName = new FieldApi({ + form, + name: 'firstName', + validators: { + onDynamic: ({ value }) => value.length >= 3 ? undefined : 'First name must be at least 3 characters long' + }, + }) + fieldFirstName.mount() + + const fieldLastName = new FieldApi({ + form, + name: 'lastName', + validators: { + onDynamic: ({ value }) => value.length >= 3 ? undefined : 'Last name must be at least 3 characters long' + }, + }) + fieldLastName.mount() + + expect(fieldFirstName.state.value).toBe('') + expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined) + + // Should not validate on change initially + fieldFirstName.handleChange('Jo') + expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined) + + // But validation should occur immediately on blur + fieldFirstName.handleBlur() + expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe('First name must be at least 3 characters long') + + // And after that point, validation should occur on change + fieldFirstName.handleChange('Matt') + expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined) + + // This field hasn't been touched, so it shouldn't have an error + expect(fieldLastName.state.value).toBe('') + expect(fieldLastName.state.meta.errorMap.onDynamic).toBe(undefined) + + // But after form submission, it should immediately have an error + await form.handleSubmit() + expect(fieldLastName.state.meta.errorMap.onDynamic).toBe('Last name must be at least 3 characters long') + + // And it should immediately validate on change now + fieldLastName.handleChange('Smith') + expect(fieldLastName.state.meta.errorMap.onDynamic).toBe(undefined) + }) + + it('should support async validation', async () => { + vi.useFakeTimers() + + const form = new FormApi({ + defaultValues: { + firstName: '', + lastName: '', + }, + validationLogic, + }) + form.mount() + + const fieldFirstName = new FieldApi({ + form, + name: 'firstName', + validators: { + onDynamicAsync: async ({ value }) => { + await sleep(100) + return value.length >= 3 ? undefined : 'First name must be at least 3 characters long' + }, + }, + }) + fieldFirstName.mount() + + const fieldLastName = new FieldApi({ + form, + name: 'lastName', + validators: { + onDynamicAsync: async ({ value }) => { + await sleep(100) + return value.length >= 3 ? undefined : 'Last name must be at least 3 characters long' + }, + }, + }) + fieldLastName.mount() + + expect(fieldFirstName.state.value).toBe('') + expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined) + + // Should not validate on change initially + fieldFirstName.handleChange('Jo') + expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined) + + // But validation should occur immediately on blur + fieldFirstName.handleBlur() + await vi.runAllTimersAsync() + expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe('First name must be at least 3 characters long') + + // And after that point, validation should occur on change + fieldFirstName.handleChange('Matt') + await vi.runAllTimersAsync() + expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe(undefined) + + // This field hasn't been touched, so it shouldn't have an error + expect(fieldLastName.state.value).toBe('') + expect(fieldLastName.state.meta.errorMap.onDynamic).toBe(undefined) + + // But after form submission, it should immediately have an error + const submitPromise = form.handleSubmit() + await vi.runAllTimersAsync() + await submitPromise + expect(fieldLastName.state.meta.errorMap.onDynamic).toBe('Last name must be at least 3 characters long') + + // And it should immediately validate on change now + fieldLastName.handleChange('Smith') + await vi.runAllTimersAsync() + expect(fieldLastName.state.meta.errorMap.onDynamic).toBe(undefined) + }) + }); }) From 58473fe585b3cc79f81876c4d702efe3dd9fddfb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:48:13 +0000 Subject: [PATCH 2/2] ci: apply automated fixes and generate docs --- .../form-core/tests/DynamicValidation.spec.ts | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/form-core/tests/DynamicValidation.spec.ts b/packages/form-core/tests/DynamicValidation.spec.ts index 9310a57e74..a89a1de364 100644 --- a/packages/form-core/tests/DynamicValidation.spec.ts +++ b/packages/form-core/tests/DynamicValidation.spec.ts @@ -3,7 +3,11 @@ import { z } from 'zod' import { FieldApi, FormApi } from '../src/index' import { defaultValidationLogic, revalidateLogic } from '../src/ValidationLogic' import { sleep } from './utils' -import type { ValidationLogicFn, ValidationLogicProps, ValidationLogicValidatorsFn } from '../src/ValidationLogic' +import type { + ValidationLogicFn, + ValidationLogicProps, + ValidationLogicValidatorsFn, +} from '../src/ValidationLogic' describe('custom validation', () => { it('should handle default validation logic', async () => { @@ -251,16 +255,22 @@ describe('custom validation', () => { defaultValidationLogic({ ...props, runValidation: (vProps) => { - validators = vProps.validators.slice() as ValidationLogicValidatorsFn[] - } + validators = + vProps.validators.slice() as ValidationLogicValidatorsFn[] + }, }) let addDynamicValidator = props.event.type === 'submit' if (!addDynamicValidator) { const hasFormSubmitted = props.form.state.submissionAttempts > 0 - const modesToWatch: ValidationLogicProps['event']['type'][] = hasFormSubmitted ? ['change'] : (props.event.fieldName ? ( - props.form.state.fieldMeta[props.event.fieldName]?.isBlurred ? ['change', 'blur'] : ['blur'] - ) : ['blur']) + const modesToWatch: ValidationLogicProps['event']['type'][] = + hasFormSubmitted + ? ['change'] + : props.event.fieldName + ? props.form.state.fieldMeta[props.event.fieldName]?.isBlurred + ? ['change', 'blur'] + : ['blur'] + : ['blur'] addDynamicValidator = modesToWatch.includes(props.event.type) } if (addDynamicValidator) { @@ -292,7 +302,10 @@ describe('custom validation', () => { form, name: 'firstName', validators: { - onDynamic: ({ value }) => value.length >= 3 ? undefined : 'First name must be at least 3 characters long' + onDynamic: ({ value }) => + value.length >= 3 + ? undefined + : 'First name must be at least 3 characters long', }, }) fieldFirstName.mount() @@ -301,7 +314,10 @@ describe('custom validation', () => { form, name: 'lastName', validators: { - onDynamic: ({ value }) => value.length >= 3 ? undefined : 'Last name must be at least 3 characters long' + onDynamic: ({ value }) => + value.length >= 3 + ? undefined + : 'Last name must be at least 3 characters long', }, }) fieldLastName.mount() @@ -315,7 +331,9 @@ describe('custom validation', () => { // But validation should occur immediately on blur fieldFirstName.handleBlur() - expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe('First name must be at least 3 characters long') + expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe( + 'First name must be at least 3 characters long', + ) // And after that point, validation should occur on change fieldFirstName.handleChange('Matt') @@ -327,7 +345,9 @@ describe('custom validation', () => { // But after form submission, it should immediately have an error await form.handleSubmit() - expect(fieldLastName.state.meta.errorMap.onDynamic).toBe('Last name must be at least 3 characters long') + expect(fieldLastName.state.meta.errorMap.onDynamic).toBe( + 'Last name must be at least 3 characters long', + ) // And it should immediately validate on change now fieldLastName.handleChange('Smith') @@ -352,7 +372,9 @@ describe('custom validation', () => { validators: { onDynamicAsync: async ({ value }) => { await sleep(100) - return value.length >= 3 ? undefined : 'First name must be at least 3 characters long' + return value.length >= 3 + ? undefined + : 'First name must be at least 3 characters long' }, }, }) @@ -364,7 +386,9 @@ describe('custom validation', () => { validators: { onDynamicAsync: async ({ value }) => { await sleep(100) - return value.length >= 3 ? undefined : 'Last name must be at least 3 characters long' + return value.length >= 3 + ? undefined + : 'Last name must be at least 3 characters long' }, }, }) @@ -380,7 +404,9 @@ describe('custom validation', () => { // But validation should occur immediately on blur fieldFirstName.handleBlur() await vi.runAllTimersAsync() - expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe('First name must be at least 3 characters long') + expect(fieldFirstName.state.meta.errorMap.onDynamic).toBe( + 'First name must be at least 3 characters long', + ) // And after that point, validation should occur on change fieldFirstName.handleChange('Matt') @@ -395,12 +421,14 @@ describe('custom validation', () => { const submitPromise = form.handleSubmit() await vi.runAllTimersAsync() await submitPromise - expect(fieldLastName.state.meta.errorMap.onDynamic).toBe('Last name must be at least 3 characters long') + expect(fieldLastName.state.meta.errorMap.onDynamic).toBe( + 'Last name must be at least 3 characters long', + ) // And it should immediately validate on change now fieldLastName.handleChange('Smith') await vi.runAllTimersAsync() expect(fieldLastName.state.meta.errorMap.onDynamic).toBe(undefined) }) - }); + }) })