From 9b96af3cce4eda44d835a9f09c1f499236f8b597 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Tue, 2 Dec 2025 10:17:38 +0900 Subject: [PATCH 1/6] feat(form-core): add onDynamicListenTo validator option - Add onDynamicListenTo to FieldValidators interface - Implement dynamic cause handling in getLinkedFields method - Add password/confirmPassword example demonstrating cross-field validation --- examples/react/simple/src/index.tsx | 55 +++++++++++++++++++++++++++++ packages/form-core/src/FieldApi.ts | 9 ++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/examples/react/simple/src/index.tsx b/examples/react/simple/src/index.tsx index 4228f1e0e..1c872198d 100644 --- a/examples/react/simple/src/index.tsx +++ b/examples/react/simple/src/index.tsx @@ -23,6 +23,8 @@ export default function App() { defaultValues: { firstName: '', lastName: '', + password: '', + confirmPassword: '', }, onSubmit: async ({ value }) => { // Do something with form data @@ -95,6 +97,59 @@ export default function App() { )} /> +
+ + !value + ? 'A password is required' + : value.length < 6 + ? 'Password must be at least 6 characters' + : undefined, + }} + children={(field) => ( + <> + + field.handleChange(e.target.value)} + /> + + + )} + /> +
+
+ { + const password = fieldApi.form.getFieldValue('password') + return value !== password ? 'Passwords must match' : undefined + }, + onDynamicListenTo: ['password'], + }} + children={(field) => ( + <> + + field.handleChange(e.target.value)} + /> + + + )} + /> +
[state.canSubmit, state.isSubmitting]} children={([canSubmit, isSubmitting]) => ( diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 7a97afe71..b7b52da9d 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -370,6 +370,10 @@ export interface FieldValidators< onDynamic?: TOnDynamic onDynamicAsync?: TOnDynamicAsync onDynamicAsyncDebounceMs?: number + /** + * An optional list of field names that should trigger this field's `onDynamic` and `onDynamicAsync` events when its value changes + */ + onDynamicListenTo?: DeepKeys[] } export interface FieldListeners< @@ -1536,7 +1540,7 @@ export class FieldApi< const linkedFields: AnyFieldApi[] = [] for (const field of fields) { if (!field.instance) continue - const { onChangeListenTo, onBlurListenTo } = + const { onChangeListenTo, onBlurListenTo, onDynamicListenTo } = field.instance.options.validators || {} if (cause === 'change' && onChangeListenTo?.includes(this.name)) { linkedFields.push(field.instance) @@ -1544,6 +1548,9 @@ export class FieldApi< if (cause === 'blur' && onBlurListenTo?.includes(this.name as string)) { linkedFields.push(field.instance) } + if (cause === 'dynamic' && onDynamicListenTo?.includes(this.name as string)) { + linkedFields.push(field.instance) + } } return linkedFields From 27a14c0778d24a74137a9c266a088b0125c14a21 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 01:20:25 +0000 Subject: [PATCH 2/6] ci: apply automated fixes and generate docs --- packages/form-core/src/FieldApi.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index b7b52da9d..f0c1d36c2 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1548,7 +1548,10 @@ export class FieldApi< if (cause === 'blur' && onBlurListenTo?.includes(this.name as string)) { linkedFields.push(field.instance) } - if (cause === 'dynamic' && onDynamicListenTo?.includes(this.name as string)) { + if ( + cause === 'dynamic' && + onDynamicListenTo?.includes(this.name as string) + ) { linkedFields.push(field.instance) } } From cdce7dd041df64970af587742b84c9e9de35b01e Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Wed, 3 Dec 2025 10:20:07 +0900 Subject: [PATCH 3/6] test(form-core): add unit tests for onDynamicListenTo - Add test for onDynamic cross-field validation with onDynamicListenTo - Add test for onDynamicAsync cross-field validation with onDynamicListenTo - Fix getLinkedFields to return validator cause for each linked field - Add 'dynamic' case to defaultValidationLogic - Clear dynamic errors when running change/blur validation --- packages/form-core/src/FieldApi.ts | 25 +++-- packages/form-core/src/ValidationLogic.ts | 25 ++++- packages/form-core/tests/FieldApi.spec.ts | 130 ++++++++++++++++++++++ 3 files changed, 165 insertions(+), 15 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index f0c1d36c2..59706ad77 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1537,22 +1537,25 @@ export class FieldApi< getLinkedFields = (cause: ValidationCause) => { const fields = Object.values(this.form.fieldInfo) as FieldInfo[] - const linkedFields: AnyFieldApi[] = [] + const linkedFields: Array<{ + field: AnyFieldApi + validatorCause: ValidationCause + }> = [] for (const field of fields) { if (!field.instance) continue const { onChangeListenTo, onBlurListenTo, onDynamicListenTo } = field.instance.options.validators || {} if (cause === 'change' && onChangeListenTo?.includes(this.name)) { - linkedFields.push(field.instance) + linkedFields.push({ field: field.instance, validatorCause: 'change' }) } if (cause === 'blur' && onBlurListenTo?.includes(this.name as string)) { - linkedFields.push(field.instance) + linkedFields.push({ field: field.instance, validatorCause: 'blur' }) } if ( - cause === 'dynamic' && + (cause === 'change' || cause === 'blur') && onDynamicListenTo?.includes(this.name as string) ) { - linkedFields.push(field.instance) + linkedFields.push({ field: field.instance, validatorCause: 'dynamic' }) } } @@ -1575,8 +1578,8 @@ export class FieldApi< const linkedFields = this.getLinkedFields(cause) const linkedFieldValidates = linkedFields.reduce( - (acc, field) => { - const fieldValidates = getSyncValidatorArray(cause, { + (acc, { field, validatorCause }) => { + const fieldValidates = getSyncValidatorArray(validatorCause, { ...field.options, form: field.form, validationLogic: @@ -1714,8 +1717,8 @@ export class FieldApi< const linkedFields = this.getLinkedFields(cause) const linkedFieldValidates = linkedFields.reduce( - (acc, field) => { - const fieldValidates = getAsyncValidatorArray(cause, { + (acc, { field, validatorCause }) => { + const fieldValidates = getAsyncValidatorArray(validatorCause, { ...field.options, form: field.form, validationLogic: @@ -1737,7 +1740,7 @@ export class FieldApi< this.setMeta((prev) => ({ ...prev, isValidating: true })) } - for (const linkedField of linkedFields) { + for (const { field: linkedField } of linkedFields) { linkedField.setMeta((prev) => ({ ...prev, isValidating: true })) } @@ -1852,7 +1855,7 @@ export class FieldApi< this.setMeta((prev) => ({ ...prev, isValidating: false })) - for (const linkedField of linkedFields) { + for (const { field: linkedField } of linkedFields) { linkedField.setMeta((prev) => ({ ...prev, isValidating: false })) } diff --git a/packages/form-core/src/ValidationLogic.ts b/packages/form-core/src/ValidationLogic.ts index e37449528..e92f0d12f 100644 --- a/packages/form-core/src/ValidationLogic.ts +++ b/packages/form-core/src/ValidationLogic.ts @@ -147,11 +147,21 @@ export const defaultValidationLogic: ValidationLogicFn = (props) => { cause: 'submit', } as const + const onDynamicValidator = { + fn: isAsync ? props.validators.onDynamicAsync : props.validators.onDynamic, + cause: 'dynamic', + } as const + // Allows us to clear onServer errors const onServerValidator = isAsync ? undefined : ({ fn: () => undefined, cause: 'server' } as const) + // Allows us to clear onDynamic errors + const onDynamicClearValidator = isAsync + ? undefined + : ({ fn: () => undefined, cause: 'dynamic' } as const) + switch (props.event.type) { case 'mount': { // Run mount validation @@ -180,16 +190,23 @@ export const defaultValidationLogic: ValidationLogicFn = (props) => { }) } case 'blur': { - // Run blur, server validation + // Run blur, server validation, clear dynamic errors return props.runValidation({ - validators: [onBlurValidator, onServerValidator], + validators: [onBlurValidator, onServerValidator, onDynamicClearValidator], form: props.form, }) } case 'change': { - // Run change, server validation + // Run change, server validation, clear dynamic errors + return props.runValidation({ + validators: [onChangeValidator, onServerValidator, onDynamicClearValidator], + form: props.form, + }) + } + case 'dynamic': { + // Run dynamic, server validation return props.runValidation({ - validators: [onChangeValidator, onServerValidator], + validators: [onDynamicValidator, onServerValidator], form: props.form, }) } diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 320ac1aea..de27a342c 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -1908,6 +1908,136 @@ describe('field api', () => { ]) }) + it('should run onDynamic on a linked field', () => { + const form = new FormApi({ + defaultValues: { + password: '', + confirm_password: '', + }, + }) + + form.mount() + + const passField = new FieldApi({ + form, + name: 'password', + }) + + const passconfirmField = new FieldApi({ + form, + name: 'confirm_password', + validators: { + onDynamicListenTo: ['password'], + onChange: ({ value, fieldApi }) => { + if (value !== fieldApi.form.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + onDynamic: ({ value, fieldApi }) => { + if (value !== fieldApi.form.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + }, + }) + + passField.mount() + passconfirmField.mount() + + passField.setValue('one') + expect(passconfirmField.getMeta().isValid).toBe(false) + expect(passconfirmField.state.meta.errors).toStrictEqual([ + 'Passwords do not match', + ]) + passconfirmField.setValue('one') + expect(passconfirmField.getMeta().isValid).toBe(true) + expect(passconfirmField.state.meta.errors).toStrictEqual([]) + passField.setValue('two') + expect(passconfirmField.getMeta().isValid).toBe(false) + expect(passconfirmField.state.meta.errors).toStrictEqual([ + 'Passwords do not match', + ]) + }) + + it('should run onDynamicAsync on a linked field', async () => { + vi.useFakeTimers() + let resolve!: () => void + let promise = new Promise((r) => { + resolve = r as never + }) + + const fn = vi.fn() + + const form = new FormApi({ + defaultValues: { + password: '', + confirm_password: '', + }, + }) + + form.mount() + + const passField = new FieldApi({ + form, + name: 'password', + }) + + const passconfirmField = new FieldApi({ + form, + name: 'confirm_password', + validators: { + onDynamicListenTo: ['password'], + onChangeAsync: async ({ value, fieldApi }) => { + await promise + fn() + if (value !== fieldApi.form.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + onDynamicAsync: async ({ value, fieldApi }) => { + await promise + fn() + if (value !== fieldApi.form.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + }, + }) + + passField.mount() + passconfirmField.mount() + + passField.setValue('one') + resolve() + await vi.runAllTimersAsync() + expect(passconfirmField.getMeta().isValid).toBe(false) + expect(passconfirmField.state.meta.errors).toStrictEqual([ + 'Passwords do not match', + ]) + promise = new Promise((r) => { + resolve = r as never + }) + passconfirmField.setValue('one') + resolve() + await vi.runAllTimersAsync() + expect(passconfirmField.getMeta().isValid).toBe(true) + expect(passconfirmField.state.meta.errors).toStrictEqual([]) + promise = new Promise((r) => { + resolve = r as never + }) + passField.setValue('two') + resolve() + await vi.runAllTimersAsync() + expect(passconfirmField.getMeta().isValid).toBe(false) + expect(passconfirmField.state.meta.errors).toStrictEqual([ + 'Passwords do not match', + ]) + }) + it('should add a new value to the fieldApi errorMap', () => { interface Form { name: string From 0c7b91f58abb72579a4a286206e923645a681006 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 01:20:57 +0000 Subject: [PATCH 4/6] ci: apply automated fixes and generate docs --- packages/form-core/src/ValidationLogic.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/ValidationLogic.ts b/packages/form-core/src/ValidationLogic.ts index e92f0d12f..897b10911 100644 --- a/packages/form-core/src/ValidationLogic.ts +++ b/packages/form-core/src/ValidationLogic.ts @@ -192,14 +192,22 @@ export const defaultValidationLogic: ValidationLogicFn = (props) => { case 'blur': { // Run blur, server validation, clear dynamic errors return props.runValidation({ - validators: [onBlurValidator, onServerValidator, onDynamicClearValidator], + validators: [ + onBlurValidator, + onServerValidator, + onDynamicClearValidator, + ], form: props.form, }) } case 'change': { // Run change, server validation, clear dynamic errors return props.runValidation({ - validators: [onChangeValidator, onServerValidator, onDynamicClearValidator], + validators: [ + onChangeValidator, + onServerValidator, + onDynamicClearValidator, + ], form: props.form, }) } From e73490759819503d357ffb5abfd0457b6e31da61 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Wed, 3 Dec 2025 10:50:21 +0900 Subject: [PATCH 5/6] fix(form-core): add 'dynamic' to ValidationCause type and fix typo --- packages/form-core/src/FieldApi.ts | 63 ++++++++++++++++++++--- packages/form-core/src/ValidationLogic.ts | 23 ++------- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 59706ad77..a9b8b4adf 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1366,6 +1366,23 @@ export class FieldApi< } if (!options?.dontValidate) { + const dynamicErrKey = getErrorMapKey('dynamic') + if ( + this.state.meta.errorMap?.[dynamicErrKey] && + this.state.meta.errorSourceMap?.[dynamicErrKey] === 'field' + ) { + this.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [dynamicErrKey]: undefined, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [dynamicErrKey]: undefined, + }, + })) + } this.validate('change') } } @@ -1651,9 +1668,25 @@ export class FieldApi< for (const validateObj of validates) { validateFieldFn(this, validateObj) } - for (const fieldValitateObj of linkedFieldValidates) { - if (!fieldValitateObj.validate) continue - validateFieldFn(fieldValitateObj.field, fieldValitateObj) + for (const fieldValidateObj of linkedFieldValidates) { + if (!fieldValidateObj.validate) continue + if (fieldValidateObj.cause === 'dynamic') { + const dynamicErrKey = getErrorMapKey('dynamic') + if (fieldValidateObj.field.state.meta.errorMap?.[dynamicErrKey]) { + fieldValidateObj.field.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [dynamicErrKey]: undefined, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [dynamicErrKey]: undefined, + }, + })) + } + } + validateFieldFn(fieldValidateObj.field, fieldValidateObj) } }) @@ -1838,11 +1871,27 @@ export class FieldApi< if (!validateObj.validate) continue validateFieldAsyncFn(this, validateObj, validatesPromises) } - for (const fieldValitateObj of linkedFieldValidates) { - if (!fieldValitateObj.validate) continue + for (const fieldValidateObj of linkedFieldValidates) { + if (!fieldValidateObj.validate) continue + if (fieldValidateObj.cause === 'dynamic') { + const dynamicErrKey = getErrorMapKey('dynamic') + if (fieldValidateObj.field.state.meta.errorMap?.[dynamicErrKey]) { + fieldValidateObj.field.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [dynamicErrKey]: undefined, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [dynamicErrKey]: undefined, + }, + })) + } + } validateFieldAsyncFn( - fieldValitateObj.field, - fieldValitateObj, + fieldValidateObj.field, + fieldValidateObj, linkedPromises, ) } diff --git a/packages/form-core/src/ValidationLogic.ts b/packages/form-core/src/ValidationLogic.ts index 897b10911..2d358936a 100644 --- a/packages/form-core/src/ValidationLogic.ts +++ b/packages/form-core/src/ValidationLogic.ts @@ -26,7 +26,7 @@ export interface ValidationLogicProps { | undefined | null event: { - type: 'blur' | 'change' | 'submit' | 'mount' | 'server' + type: 'blur' | 'change' | 'submit' | 'mount' | 'server' | 'dynamic' fieldName?: string async: boolean } @@ -157,11 +157,6 @@ export const defaultValidationLogic: ValidationLogicFn = (props) => { ? undefined : ({ fn: () => undefined, cause: 'server' } as const) - // Allows us to clear onDynamic errors - const onDynamicClearValidator = isAsync - ? undefined - : ({ fn: () => undefined, cause: 'dynamic' } as const) - switch (props.event.type) { case 'mount': { // Run mount validation @@ -190,24 +185,16 @@ export const defaultValidationLogic: ValidationLogicFn = (props) => { }) } case 'blur': { - // Run blur, server validation, clear dynamic errors + // Run blur, server validation return props.runValidation({ - validators: [ - onBlurValidator, - onServerValidator, - onDynamicClearValidator, - ], + validators: [onBlurValidator, onServerValidator], form: props.form, }) } case 'change': { - // Run change, server validation, clear dynamic errors + // Run change, server validation return props.runValidation({ - validators: [ - onChangeValidator, - onServerValidator, - onDynamicClearValidator, - ], + validators: [onChangeValidator, onServerValidator], form: props.form, }) } From 5f7b42017f71dde34f2a5cccb3c573f56a3f6a38 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Wed, 3 Dec 2025 10:56:53 +0900 Subject: [PATCH 6/6] fix(form-core): remove unnecessary optional chaining in FieldApi --- packages/form-core/src/FieldApi.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index a9b8b4adf..2f9ad8cbd 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1368,8 +1368,8 @@ export class FieldApi< if (!options?.dontValidate) { const dynamicErrKey = getErrorMapKey('dynamic') if ( - this.state.meta.errorMap?.[dynamicErrKey] && - this.state.meta.errorSourceMap?.[dynamicErrKey] === 'field' + this.state.meta.errorMap[dynamicErrKey] && + this.state.meta.errorSourceMap[dynamicErrKey] === 'field' ) { this.setMeta((prev) => ({ ...prev, @@ -1672,7 +1672,7 @@ export class FieldApi< if (!fieldValidateObj.validate) continue if (fieldValidateObj.cause === 'dynamic') { const dynamicErrKey = getErrorMapKey('dynamic') - if (fieldValidateObj.field.state.meta.errorMap?.[dynamicErrKey]) { + if (fieldValidateObj.field.state.meta.errorMap[dynamicErrKey]) { fieldValidateObj.field.setMeta((prev) => ({ ...prev, errorMap: { @@ -1875,7 +1875,7 @@ export class FieldApi< if (!fieldValidateObj.validate) continue if (fieldValidateObj.cause === 'dynamic') { const dynamicErrKey = getErrorMapKey('dynamic') - if (fieldValidateObj.field.state.meta.errorMap?.[dynamicErrKey]) { + if (fieldValidateObj.field.state.meta.errorMap[dynamicErrKey]) { fieldValidateObj.field.setMeta((prev) => ({ ...prev, errorMap: {