From b53bf0d9bf2e89fde9a491bf9cce88717e0c998b Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Thu, 14 Aug 2025 14:54:18 +0900 Subject: [PATCH 01/16] test: field mount bug test --- .../tests/hidden-field-mount.test.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 packages/form-core/tests/hidden-field-mount.test.ts diff --git a/packages/form-core/tests/hidden-field-mount.test.ts b/packages/form-core/tests/hidden-field-mount.test.ts new file mode 100644 index 000000000..a2d0c4713 --- /dev/null +++ b/packages/form-core/tests/hidden-field-mount.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { FieldApi, FormApi } from '../src' + +describe('Hidden field mount validation', () => { + it('should display errors on delayed field mount', async () => { + const form = new FormApi({ + defaultValues: { + hiddenField: '', + visibleField: '' + }, + validators: { + onMount: ({ value }) => { + const errors: Record = {} + if (!value.hiddenField) { + errors.hiddenField = 'Hidden field is required' + } + if (!value.visibleField) { + errors.visibleField = 'Visible field is required' + } + return { fields: errors } + } + } + }) + + form.mount() + + const visibleField = new FieldApi({ + form, + name: 'visibleField' + }) + visibleField.mount() + + await new Promise(resolve => setTimeout(resolve, 100)) + + const hiddenField = new FieldApi({ + form, + name: 'hiddenField' + }) + hiddenField.mount() + + expect(hiddenField.state.meta.errors).toContain('Hidden field is required') + + expect(visibleField.state.meta.errors).toContain('Visible field is required') + }) + + it('should sync errors for multiple delayed fields', async () => { + const form = new FormApi({ + defaultValues: { + field1: '', + field2: '', + field3: '' + }, + validators: { + onMount: () => { + return { + fields: { + field1: 'Field 1 error', + field2: 'Field 2 error', + field3: 'Field 3 error' + } + } + } + } + }) + + form.mount() + + const field1 = new FieldApi({ form, name: 'field1' }) + field1.mount() + + await new Promise(resolve => setTimeout(resolve, 50)) + + const field2 = new FieldApi({ form, name: 'field2' }) + field2.mount() + + await new Promise(resolve => setTimeout(resolve, 50)) + + const field3 = new FieldApi({ form, name: 'field3' }) + field3.mount() + + expect(field1.state.meta.errors).toContain('Field 1 error') + expect(field2.state.meta.errors).toContain('Field 2 error') + expect(field3.state.meta.errors).toContain('Field 3 error') + }) + + it('should handle field remount scenarios', () => { + const form = new FormApi({ + defaultValues: { + remountField: '' + }, + validators: { + onMount: ({ value }) => { + if (!value.remountField) { + return { fields: { remountField: 'Remount field is required' } } + } + return undefined + } + } + }) + + form.mount() + + const field = new FieldApi({ form, name: 'remountField' }) + const cleanup1 = field.mount() + + expect(field.state.meta.errors).toContain('Remount field is required') + + cleanup1() + + const cleanup2 = field.mount() + expect(field.state.meta.errors).toContain('Remount field is required') + + cleanup2() + }) +}) From 1e84a780a5d0d909e6520654d83ad9c461d677d9 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Thu, 14 Aug 2025 14:57:57 +0900 Subject: [PATCH 02/16] feat: implement form error persistence for delayed-mounted fields --- packages/form-core/src/FieldApi.ts | 22 ++++++++++++++++++++++ packages/form-core/src/FormApi.ts | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index a343bda81..8747bffc1 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1241,6 +1241,28 @@ export class FieldApi< info.instance = this as never this.update(this.options as never) + + const allFieldErrors = this.form.state._allFieldErrors + if (allFieldErrors?.[this.name]) { + const existingErrorMap = allFieldErrors[this.name] + if (existingErrorMap) { + this.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + ...existingErrorMap, + }, + errorSourceMap: { + ...prev.errorSourceMap, + ...Object.keys(existingErrorMap).reduce((acc, key) => { + acc[key as keyof typeof acc] = 'form' + return acc + }, {} as Record), + }, + }) as never) + } + } + const { onMount } = this.options.validators || {} if (onMount) { diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 0c62c5cd6..4dea880b1 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -632,6 +632,11 @@ export type BaseFormState< * @private, used to force a re-evaluation of the form state when options change */ _force_re_eval?: boolean + /** + * @private, stores field errors for all fields (including unmounted ones) + * This allows delayed-mounted fields to sync with existing validation errors + */ + _allFieldErrors?: Partial, ValidationErrorMap>> } export type DerivedFormState< @@ -1532,6 +1537,23 @@ export class FormApi< const errorMapKey = getErrorMapKey(validateObj.cause) + if (fieldErrors) { + const allFieldErrors: Partial, ValidationErrorMap>> = this.state._allFieldErrors || {} + for (const [fieldName, fieldError] of Object.entries(fieldErrors)) { + if (fieldError) { + const typedFieldName = fieldName as DeepKeys + allFieldErrors[typedFieldName] = { + ...allFieldErrors[typedFieldName], + [errorMapKey]: fieldError, + } + } + } + this.baseStore.setState((prev) => ({ + ...prev, + _allFieldErrors: allFieldErrors, + })) + } + for (const field of Object.keys( this.state.fieldMeta, ) as DeepKeys[]) { From ed84add280e2bcaea2a84dafd27ee43a191af326 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Thu, 14 Aug 2025 14:58:55 +0900 Subject: [PATCH 03/16] refactor: rename hidden field mount test file to use .test-d.ts extension --- .../{hidden-field-mount.test.ts => hidden-field-mount.test-d.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/form-core/tests/{hidden-field-mount.test.ts => hidden-field-mount.test-d.ts} (100%) diff --git a/packages/form-core/tests/hidden-field-mount.test.ts b/packages/form-core/tests/hidden-field-mount.test-d.ts similarity index 100% rename from packages/form-core/tests/hidden-field-mount.test.ts rename to packages/form-core/tests/hidden-field-mount.test-d.ts From 10c24afd3cbb8b6ff43f970b1368528575b2698a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:31:41 +0000 Subject: [PATCH 04/16] ci: apply automated fixes and generate docs --- packages/form-core/src/FieldApi.ts | 34 +++++---- packages/form-core/src/FormApi.ts | 4 +- .../tests/hidden-field-mount.test-d.ts | 74 ++++++++++--------- 3 files changed, 61 insertions(+), 51 deletions(-) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 8747bffc1..5a2915c28 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1246,20 +1246,26 @@ export class FieldApi< if (allFieldErrors?.[this.name]) { const existingErrorMap = allFieldErrors[this.name] if (existingErrorMap) { - this.setMeta((prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - ...existingErrorMap, - }, - errorSourceMap: { - ...prev.errorSourceMap, - ...Object.keys(existingErrorMap).reduce((acc, key) => { - acc[key as keyof typeof acc] = 'form' - return acc - }, {} as Record), - }, - }) as never) + this.setMeta( + (prev) => + ({ + ...prev, + errorMap: { + ...prev.errorMap, + ...existingErrorMap, + }, + errorSourceMap: { + ...prev.errorSourceMap, + ...Object.keys(existingErrorMap).reduce( + (acc, key) => { + acc[key as keyof typeof acc] = 'form' + return acc + }, + {} as Record, + ), + }, + }) as never, + ) } } diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 4dea880b1..22a88a541 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1538,7 +1538,9 @@ export class FormApi< const errorMapKey = getErrorMapKey(validateObj.cause) if (fieldErrors) { - const allFieldErrors: Partial, ValidationErrorMap>> = this.state._allFieldErrors || {} + const allFieldErrors: Partial< + Record, ValidationErrorMap> + > = this.state._allFieldErrors || {} for (const [fieldName, fieldError] of Object.entries(fieldErrors)) { if (fieldError) { const typedFieldName = fieldName as DeepKeys diff --git a/packages/form-core/tests/hidden-field-mount.test-d.ts b/packages/form-core/tests/hidden-field-mount.test-d.ts index a2d0c4713..0b46c08aa 100644 --- a/packages/form-core/tests/hidden-field-mount.test-d.ts +++ b/packages/form-core/tests/hidden-field-mount.test-d.ts @@ -6,7 +6,7 @@ describe('Hidden field mount validation', () => { const form = new FormApi({ defaultValues: { hiddenField: '', - visibleField: '' + visibleField: '', }, validators: { onMount: ({ value }) => { @@ -18,29 +18,31 @@ describe('Hidden field mount validation', () => { errors.visibleField = 'Visible field is required' } return { fields: errors } - } - } + }, + }, }) - + form.mount() - + const visibleField = new FieldApi({ form, - name: 'visibleField' + name: 'visibleField', }) visibleField.mount() - - await new Promise(resolve => setTimeout(resolve, 100)) - + + await new Promise((resolve) => setTimeout(resolve, 100)) + const hiddenField = new FieldApi({ form, - name: 'hiddenField' + name: 'hiddenField', }) hiddenField.mount() - + expect(hiddenField.state.meta.errors).toContain('Hidden field is required') - - expect(visibleField.state.meta.errors).toContain('Visible field is required') + + expect(visibleField.state.meta.errors).toContain( + 'Visible field is required', + ) }) it('should sync errors for multiple delayed fields', async () => { @@ -48,7 +50,7 @@ describe('Hidden field mount validation', () => { defaultValues: { field1: '', field2: '', - field3: '' + field3: '', }, validators: { onMount: () => { @@ -56,28 +58,28 @@ describe('Hidden field mount validation', () => { fields: { field1: 'Field 1 error', field2: 'Field 2 error', - field3: 'Field 3 error' - } + field3: 'Field 3 error', + }, } - } - } + }, + }, }) - + form.mount() - + const field1 = new FieldApi({ form, name: 'field1' }) field1.mount() - - await new Promise(resolve => setTimeout(resolve, 50)) - + + await new Promise((resolve) => setTimeout(resolve, 50)) + const field2 = new FieldApi({ form, name: 'field2' }) field2.mount() - - await new Promise(resolve => setTimeout(resolve, 50)) - + + await new Promise((resolve) => setTimeout(resolve, 50)) + const field3 = new FieldApi({ form, name: 'field3' }) field3.mount() - + expect(field1.state.meta.errors).toContain('Field 1 error') expect(field2.state.meta.errors).toContain('Field 2 error') expect(field3.state.meta.errors).toContain('Field 3 error') @@ -86,7 +88,7 @@ describe('Hidden field mount validation', () => { it('should handle field remount scenarios', () => { const form = new FormApi({ defaultValues: { - remountField: '' + remountField: '', }, validators: { onMount: ({ value }) => { @@ -94,22 +96,22 @@ describe('Hidden field mount validation', () => { return { fields: { remountField: 'Remount field is required' } } } return undefined - } - } + }, + }, }) - + form.mount() - + const field = new FieldApi({ form, name: 'remountField' }) const cleanup1 = field.mount() - + expect(field.state.meta.errors).toContain('Remount field is required') - + cleanup1() - + const cleanup2 = field.mount() expect(field.state.meta.errors).toContain('Remount field is required') - + cleanup2() }) }) From 0171e363bd25e988dd18fde92de837fae1c4121e Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Wed, 10 Sep 2025 13:35:22 +0900 Subject: [PATCH 05/16] fix: ensure field errors are properly cleared when removing form fields --- packages/form-core/src/FormApi.ts | 19 +- .../tests/FieldInfo-management.test.ts | 379 ++++++++++++++++++ 2 files changed, 395 insertions(+), 3 deletions(-) create mode 100644 packages/form-core/tests/FieldInfo-management.test.ts diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 22a88a541..4952e07d3 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1556,9 +1556,16 @@ export class FormApi< })) } - for (const field of Object.keys( - this.state.fieldMeta, - ) as DeepKeys[]) { + const allFieldsToProcess = new Set([ + ...Object.keys(this.state.fieldMeta), + ...Object.keys(fieldErrors || {}), + ] as DeepKeys[]) + + for (const field of allFieldsToProcess) { + if (fieldErrors?.[field] && !this.fieldInfo[field]) { + this.getFieldInfo(field) + } + const fieldMeta = this.getFieldMeta(field) if (!fieldMeta) continue @@ -2119,6 +2126,12 @@ export class FormApi< newState.values = deleteBy(newState.values, f) delete this.fieldInfo[f as never] delete newState.fieldMetaBase[f as never] + + if (newState._allFieldErrors?.[f as never]) { + const newAllFieldErrors = { ...newState._allFieldErrors } + delete newAllFieldErrors[f as never] + newState._allFieldErrors = newAllFieldErrors + } }) return newState diff --git a/packages/form-core/tests/FieldInfo-management.test.ts b/packages/form-core/tests/FieldInfo-management.test.ts new file mode 100644 index 000000000..0a91b15bd --- /dev/null +++ b/packages/form-core/tests/FieldInfo-management.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it } from 'vitest' +import { FieldApi, FormApi } from '../src' + +describe('FieldInfo Management for PR #1691', () => { + describe('Form validation with delayed field mounting', () => { + it('should auto-create fieldInfo entries for fields with validation errors', async () => { + const form = new FormApi({ + defaultValues: { + existingField: '', + delayedField: '', + }, + validators: { + onMount: ({ value }) => { + const errors: Record = {} + if (!value.existingField) { + errors.existingField = 'Existing field is required' + } + if (!value.delayedField) { + errors.delayedField = 'Delayed field is required' + } + return { fields: errors } + }, + }, + }) + + form.mount() + + const existingField = new FieldApi({ + form, + name: 'existingField', + }) + existingField.mount() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(form.fieldInfo.delayedField).toBeDefined() + expect(form.fieldInfo.delayedField.instance).toBeNull() + + const delayedField = new FieldApi({ + form, + name: 'delayedField', + }) + delayedField.mount() + + expect(delayedField.state.meta.errors).toContain('Delayed field is required') + }) + + it('should handle multiple delayed fields with different error types', async () => { + const form = new FormApi({ + defaultValues: { + field1: '', + field2: '', + field3: '', + }, + validators: { + onMount: () => { + return { + fields: { + field1: 'Field 1 error', + field2: 'Field 2 error', + field3: 'Field 3 error', + }, + } + }, + }, + }) + + form.mount() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(form.fieldInfo.field1).toBeDefined() + expect(form.fieldInfo.field2).toBeDefined() + expect(form.fieldInfo.field3).toBeDefined() + + const field1 = new FieldApi({ form, name: 'field1' }) + field1.mount() + + await new Promise((resolve) => setTimeout(resolve, 25)) + + const field2 = new FieldApi({ form, name: 'field2' }) + field2.mount() + + await new Promise((resolve) => setTimeout(resolve, 25)) + + const field3 = new FieldApi({ form, name: 'field3' }) + field3.mount() + + expect(field1.state.meta.errors).toContain('Field 1 error') + expect(field2.state.meta.errors).toContain('Field 2 error') + expect(field3.state.meta.errors).toContain('Field 3 error') + }) + }) + + describe('deleteField functionality', () => { + it('should remove field from fieldInfo and _allFieldErrors', () => { + const form = new FormApi({ + defaultValues: { + fieldToDelete: 'test', + keepField: 'keep', + }, + validators: { + onMount: () => { + return { + fields: { + fieldToDelete: 'Field error', + keepField: 'Keep field error', + }, + } + }, + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'fieldToDelete', + }) + field.mount() + + expect(form.fieldInfo.fieldToDelete).toBeDefined() + expect(form.state._allFieldErrors?.fieldToDelete).toBeDefined() + + form.deleteField('fieldToDelete') + + expect(form.fieldInfo.fieldToDelete).toBeUndefined() + expect(form.state._allFieldErrors?.fieldToDelete).toBeUndefined() + + expect(form.fieldInfo.keepField).toBeDefined() + expect(form.state._allFieldErrors?.keepField).toBeDefined() + }) + + it('should remove nested fields when parent is deleted', () => { + const form = new FormApi({ + defaultValues: { + parent: { + child1: 'value1', + child2: 'value2', + }, + otherField: 'other', + }, + }) + + form.mount() + + const parentField = new FieldApi({ form, name: 'parent' }) + const child1Field = new FieldApi({ form, name: 'parent.child1' }) + const child2Field = new FieldApi({ form, name: 'parent.child2' }) + const otherField = new FieldApi({ form, name: 'otherField' }) + + parentField.mount() + child1Field.mount() + child2Field.mount() + otherField.mount() + + expect(form.fieldInfo.parent).toBeDefined() + expect(form.fieldInfo['parent.child1']).toBeDefined() + expect(form.fieldInfo['parent.child2']).toBeDefined() + expect(form.fieldInfo.otherField).toBeDefined() + + form.deleteField('parent') + + expect(form.fieldInfo.parent).toBeUndefined() + expect(form.fieldInfo['parent.child1']).toBeUndefined() + expect(form.fieldInfo['parent.child2']).toBeUndefined() + + expect(form.fieldInfo.otherField).toBeDefined() + }) + }) + + describe('removeValue functionality', () => { + it('should remove field value and clean up fieldInfo entry', () => { + const form = new FormApi({ + defaultValues: { + fieldToRemove: 'initial value', + keepField: 'keep value', + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'fieldToRemove', + }) + const keepField = new FieldApi({ + form, + name: 'keepField', + }) + field.mount() + keepField.mount() + + expect(form.state.values.fieldToRemove).toBe('initial value') + expect(form.fieldInfo.fieldToRemove).toBeDefined() + + form.deleteField('fieldToRemove') + + expect(form.state.values.fieldToRemove).toBeUndefined() + + const fieldInfoKeys = Object.keys(form.fieldInfo) + expect(fieldInfoKeys.includes('fieldToRemove')).toBe(false) + + expect(form.state.values.keepField).toBe('keep value') + expect(form.fieldInfo.keepField).toBeDefined() + }) + + it('should remove field from _allFieldErrors when deleteField is called', () => { + const form = new FormApi({ + defaultValues: { + fieldWithError: '', + otherField: '', + }, + validators: { + onMount: () => { + return { + fields: { + fieldWithError: 'Field error', + otherField: 'Other error', + }, + } + }, + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'fieldWithError', + }) + field.mount() + + expect(form.state._allFieldErrors?.fieldWithError).toBeDefined() + expect(form.state._allFieldErrors?.otherField).toBeDefined() + + form.deleteField('fieldWithError') + + expect(form.state._allFieldErrors?.fieldWithError).toBeUndefined() + + expect(form.state._allFieldErrors?.otherField).toBeDefined() + }) + }) + + describe('Dynamic field management', () => { + it('should handle dynamic addition and removal of fields', () => { + const form = new FormApi({ + defaultValues: { + dynamicFields: [] as string[], + }, + }) + + form.mount() + + form.setFieldValue('dynamicFields', ['field1', 'field2', 'field3']) + + const field1 = new FieldApi({ form, name: 'dynamicFields[0]' }) + const field2 = new FieldApi({ form, name: 'dynamicFields[1]' }) + const field3 = new FieldApi({ form, name: 'dynamicFields[2]' }) + + field1.mount() + field2.mount() + field3.mount() + + expect(form.fieldInfo['dynamicFields[0]']).toBeDefined() + expect(form.fieldInfo['dynamicFields[1]']).toBeDefined() + expect(form.fieldInfo['dynamicFields[2]']).toBeDefined() + + form.deleteField('dynamicFields[1]') + form.deleteField('dynamicFields[2]') + + const fieldInfoKeys = Object.keys(form.fieldInfo) + expect(fieldInfoKeys.includes('dynamicFields[1]')).toBe(false) + expect(fieldInfoKeys.includes('dynamicFields[2]')).toBe(false) + + expect(form.fieldInfo['dynamicFields[0]']).toBeDefined() + }) + + it('should maintain validation state consistency during field lifecycle', async () => { + const form = new FormApi({ + defaultValues: { + showField: false, + conditionalField: '', + }, + validators: { + onChange: ({ value }) => { + if (value.showField && !value.conditionalField) { + return { + fields: { + conditionalField: 'Conditional field is required when shown', + }, + } + } + return undefined + }, + }, + }) + + form.mount() + + const showFieldApi = new FieldApi({ form, name: 'showField' }) + showFieldApi.mount() + + showFieldApi.setValue(true) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(form.fieldInfo.conditionalField).toBeDefined() + expect(form.state._allFieldErrors?.conditionalField).toBeDefined() + + const conditionalField = new FieldApi({ form, name: 'conditionalField' }) + conditionalField.mount() + + expect(conditionalField.state.meta.errors).toContain( + 'Conditional field is required when shown', + ) + + showFieldApi.setValue(false) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + form.deleteField('conditionalField') + + expect(form.fieldInfo.conditionalField).toBeUndefined() + expect(form.state._allFieldErrors?.conditionalField).toBeUndefined() + }) + }) + + describe('Edge cases and error handling', () => { + it('should handle deleteField on non-existent fields gracefully', () => { + const form = new FormApi({ + defaultValues: { + existingField: 'value', + }, + }) + + form.mount() + + expect(() => { + form.deleteField('nonExistentField' as keyof typeof form.state.values) + }).not.toThrow() + + expect(form.state.values.existingField).toBe('value') + }) + + it('should handle concurrent field operations correctly', async () => { + const form = new FormApi({ + defaultValues: { + field1: 'value1', + field2: 'value2', + field3: 'value3', + }, + }) + + form.mount() + + const field1 = new FieldApi({ form, name: 'field1' }) + const field2 = new FieldApi({ form, name: 'field2' }) + const field3 = new FieldApi({ form, name: 'field3' }) + + field1.mount() + field2.mount() + field3.mount() + + const operations = [ + () => form.deleteField('field1'), + () => form.deleteField('field2'), + () => form.setFieldValue('field3', 'new value'), + ] + + await Promise.all(operations.map((op) => Promise.resolve(op()))) + + expect(form.fieldInfo.field1).toBeUndefined() + expect(form.fieldInfo.field2).toBeUndefined() + expect(form.fieldInfo.field3).toBeDefined() + expect(form.state.values.field3).toBe('new value') + }) + }) +}) From 94dcbd5e4df32a45ab3711d68455e5a7366d66a0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 20 Sep 2025 08:50:31 +0000 Subject: [PATCH 06/16] ci: apply automated fixes and generate docs --- packages/form-core/src/FormApi.ts | 2 +- packages/form-core/tests/FieldInfo-management.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 4952e07d3..2030b5aac 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -2126,7 +2126,7 @@ export class FormApi< newState.values = deleteBy(newState.values, f) delete this.fieldInfo[f as never] delete newState.fieldMetaBase[f as never] - + if (newState._allFieldErrors?.[f as never]) { const newAllFieldErrors = { ...newState._allFieldErrors } delete newAllFieldErrors[f as never] diff --git a/packages/form-core/tests/FieldInfo-management.test.ts b/packages/form-core/tests/FieldInfo-management.test.ts index 0a91b15bd..ea10a5a1a 100644 --- a/packages/form-core/tests/FieldInfo-management.test.ts +++ b/packages/form-core/tests/FieldInfo-management.test.ts @@ -42,7 +42,9 @@ describe('FieldInfo Management for PR #1691', () => { }) delayedField.mount() - expect(delayedField.state.meta.errors).toContain('Delayed field is required') + expect(delayedField.state.meta.errors).toContain( + 'Delayed field is required', + ) }) it('should handle multiple delayed fields with different error types', async () => { From 8c4b7f56e8e6980b6aae0fde70dad3c67b5facf5 Mon Sep 17 00:00:00 2001 From: jiji-hoon96 Date: Fri, 24 Oct 2025 00:41:37 +0900 Subject: [PATCH 07/16] refactor: simplify field error management by removing _allFieldErrors state --- packages/form-core/src/FieldApi.ts | 27 -- packages/form-core/src/FormApi.ts | 35 +- packages/form-core/tests/FieldApi.spec.ts | 380 +++++++++++++++++ .../tests/FieldInfo-management.test.ts | 381 ------------------ .../tests/hidden-field-mount.test-d.ts | 117 ------ 5 files changed, 383 insertions(+), 557 deletions(-) delete mode 100644 packages/form-core/tests/FieldInfo-management.test.ts delete mode 100644 packages/form-core/tests/hidden-field-mount.test-d.ts diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 5a2915c28..d1b3d4a99 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1242,33 +1242,6 @@ export class FieldApi< this.update(this.options as never) - const allFieldErrors = this.form.state._allFieldErrors - if (allFieldErrors?.[this.name]) { - const existingErrorMap = allFieldErrors[this.name] - if (existingErrorMap) { - this.setMeta( - (prev) => - ({ - ...prev, - errorMap: { - ...prev.errorMap, - ...existingErrorMap, - }, - errorSourceMap: { - ...prev.errorSourceMap, - ...Object.keys(existingErrorMap).reduce( - (acc, key) => { - acc[key as keyof typeof acc] = 'form' - return acc - }, - {} as Record, - ), - }, - }) as never, - ) - } - } - const { onMount } = this.options.validators || {} if (onMount) { diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 2030b5aac..0f27cec23 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -632,11 +632,6 @@ export type BaseFormState< * @private, used to force a re-evaluation of the form state when options change */ _force_re_eval?: boolean - /** - * @private, stores field errors for all fields (including unmounted ones) - * This allows delayed-mounted fields to sync with existing validation errors - */ - _allFieldErrors?: Partial, ValidationErrorMap>> } export type DerivedFormState< @@ -1537,33 +1532,15 @@ export class FormApi< const errorMapKey = getErrorMapKey(validateObj.cause) - if (fieldErrors) { - const allFieldErrors: Partial< - Record, ValidationErrorMap> - > = this.state._allFieldErrors || {} - for (const [fieldName, fieldError] of Object.entries(fieldErrors)) { - if (fieldError) { - const typedFieldName = fieldName as DeepKeys - allFieldErrors[typedFieldName] = { - ...allFieldErrors[typedFieldName], - [errorMapKey]: fieldError, - } - } - } - this.baseStore.setState((prev) => ({ - ...prev, - _allFieldErrors: allFieldErrors, - })) - } - const allFieldsToProcess = new Set([ ...Object.keys(this.state.fieldMeta), ...Object.keys(fieldErrors || {}), ] as DeepKeys[]) for (const field of allFieldsToProcess) { - if (fieldErrors?.[field] && !this.fieldInfo[field]) { - this.getFieldInfo(field) + // Create fieldMeta for fields with errors if they don't exist yet + if (fieldErrors?.[field] && !this.state.fieldMeta[field]) { + this.setFieldMeta(field, () => defaultFieldMeta) } const fieldMeta = this.getFieldMeta(field) @@ -2126,12 +2103,6 @@ export class FormApi< newState.values = deleteBy(newState.values, f) delete this.fieldInfo[f as never] delete newState.fieldMetaBase[f as never] - - if (newState._allFieldErrors?.[f as never]) { - const newAllFieldErrors = { ...newState._allFieldErrors } - delete newAllFieldErrors[f as never] - newState._allFieldErrors = newAllFieldErrors - } }) return newState diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 1935e19d4..0122d710a 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2500,4 +2500,384 @@ describe('field api', () => { expect(field.state.meta.errors).toStrictEqual(['Blur error']) }) + + describe('delayed field mounting', () => { + it('should display validation errors on fields mounted after form validation', async () => { + const form = new FormApi({ + defaultValues: { + existingField: '', + delayedField: '', + }, + validators: { + onMount: ({ value }) => { + const errors: Record = {} + if (!value.existingField) { + errors.existingField = 'Existing field is required' + } + if (!value.delayedField) { + errors.delayedField = 'Delayed field is required' + } + return { fields: errors } + }, + }, + }) + + form.mount() + + const existingField = new FieldApi({ + form, + name: 'existingField', + }) + existingField.mount() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(form.state.fieldMeta.delayedField).toBeDefined() + expect(form.state.fieldMeta.delayedField.errorMap.onMount).toBe( + 'Delayed field is required', + ) + + const delayedField = new FieldApi({ + form, + name: 'delayedField', + }) + delayedField.mount() + + expect(delayedField.state.meta.errors).toContain( + 'Delayed field is required', + ) + }) + + it('should handle multiple delayed fields with different error types', async () => { + const form = new FormApi({ + defaultValues: { + field1: '', + field2: '', + field3: '', + }, + validators: { + onMount: () => { + return { + fields: { + field1: 'Field 1 error', + field2: 'Field 2 error', + field3: 'Field 3 error', + }, + } + }, + }, + }) + + form.mount() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // All fields should have fieldMeta with errors + expect(form.state.fieldMeta.field1).toBeDefined() + expect(form.state.fieldMeta.field2).toBeDefined() + expect(form.state.fieldMeta.field3).toBeDefined() + + const field1 = new FieldApi({ form, name: 'field1' }) + field1.mount() + + await new Promise((resolve) => setTimeout(resolve, 25)) + + const field2 = new FieldApi({ form, name: 'field2' }) + field2.mount() + + await new Promise((resolve) => setTimeout(resolve, 25)) + + const field3 = new FieldApi({ form, name: 'field3' }) + field3.mount() + + expect(field1.state.meta.errors).toContain('Field 1 error') + expect(field2.state.meta.errors).toContain('Field 2 error') + expect(field3.state.meta.errors).toContain('Field 3 error') + }) + }) + + describe('deleteField functionality', () => { + it('should remove field from fieldInfo and fieldMeta', () => { + const form = new FormApi({ + defaultValues: { + fieldToDelete: 'test', + keepField: 'keep', + }, + validators: { + onMount: () => { + return { + fields: { + fieldToDelete: 'Field error', + keepField: 'Keep field error', + }, + } + }, + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'fieldToDelete', + }) + field.mount() + + expect(form.fieldInfo.fieldToDelete).toBeDefined() + expect(form.state.fieldMeta.fieldToDelete).toBeDefined() + + form.deleteField('fieldToDelete') + + expect(form.fieldInfo.fieldToDelete).toBeUndefined() + expect(form.state.fieldMeta.fieldToDelete).toBeUndefined() + + expect(form.fieldInfo.keepField).toBeDefined() + expect(form.state.fieldMeta.keepField).toBeDefined() + }) + + it('should remove nested fields when parent is deleted', () => { + const form = new FormApi({ + defaultValues: { + parent: { + child1: 'value1', + child2: 'value2', + }, + otherField: 'other', + }, + }) + + form.mount() + + const parentField = new FieldApi({ form, name: 'parent' }) + const child1Field = new FieldApi({ form, name: 'parent.child1' }) + const child2Field = new FieldApi({ form, name: 'parent.child2' }) + const otherField = new FieldApi({ form, name: 'otherField' }) + + parentField.mount() + child1Field.mount() + child2Field.mount() + otherField.mount() + + expect(form.fieldInfo.parent).toBeDefined() + expect(form.fieldInfo['parent.child1']).toBeDefined() + expect(form.fieldInfo['parent.child2']).toBeDefined() + expect(form.fieldInfo.otherField).toBeDefined() + + form.deleteField('parent') + + expect(form.fieldInfo.parent).toBeUndefined() + expect(form.fieldInfo['parent.child1']).toBeUndefined() + expect(form.fieldInfo['parent.child2']).toBeUndefined() + + expect(form.fieldInfo.otherField).toBeDefined() + }) + + it('should remove field value and clean up fieldInfo entry', () => { + const form = new FormApi({ + defaultValues: { + fieldToRemove: 'initial value', + keepField: 'keep value', + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'fieldToRemove', + }) + const keepField = new FieldApi({ + form, + name: 'keepField', + }) + field.mount() + keepField.mount() + + expect(form.state.values.fieldToRemove).toBe('initial value') + expect(form.fieldInfo.fieldToRemove).toBeDefined() + + form.deleteField('fieldToRemove') + + expect(form.state.values.fieldToRemove).toBeUndefined() + + const fieldInfoKeys = Object.keys(form.fieldInfo) + expect(fieldInfoKeys.includes('fieldToRemove')).toBe(false) + + expect(form.state.values.keepField).toBe('keep value') + expect(form.fieldInfo.keepField).toBeDefined() + }) + + it('should remove field from fieldMeta when deleteField is called', () => { + const form = new FormApi({ + defaultValues: { + fieldWithError: '', + otherField: '', + }, + validators: { + onMount: () => { + return { + fields: { + fieldWithError: 'Field error', + otherField: 'Other error', + }, + } + }, + }, + }) + + form.mount() + + const field = new FieldApi({ + form, + name: 'fieldWithError', + }) + field.mount() + + expect(form.state.fieldMeta.fieldWithError).toBeDefined() + expect(form.state.fieldMeta.otherField).toBeDefined() + + form.deleteField('fieldWithError') + + expect(form.state.fieldMeta.fieldWithError).toBeUndefined() + + expect(form.state.fieldMeta.otherField).toBeDefined() + }) + }) + + describe('dynamic field management', () => { + it('should handle dynamic addition and removal of fields', () => { + const form = new FormApi({ + defaultValues: { + dynamicFields: [] as string[], + }, + }) + + form.mount() + + form.setFieldValue('dynamicFields', ['field1', 'field2', 'field3']) + + const field1 = new FieldApi({ form, name: 'dynamicFields[0]' }) + const field2 = new FieldApi({ form, name: 'dynamicFields[1]' }) + const field3 = new FieldApi({ form, name: 'dynamicFields[2]' }) + + field1.mount() + field2.mount() + field3.mount() + + expect(form.fieldInfo['dynamicFields[0]']).toBeDefined() + expect(form.fieldInfo['dynamicFields[1]']).toBeDefined() + expect(form.fieldInfo['dynamicFields[2]']).toBeDefined() + + form.deleteField('dynamicFields[1]') + form.deleteField('dynamicFields[2]') + + const fieldInfoKeys = Object.keys(form.fieldInfo) + expect(fieldInfoKeys.includes('dynamicFields[1]')).toBe(false) + expect(fieldInfoKeys.includes('dynamicFields[2]')).toBe(false) + + expect(form.fieldInfo['dynamicFields[0]']).toBeDefined() + }) + + it('should maintain validation state consistency during field lifecycle', async () => { + const form = new FormApi({ + defaultValues: { + showField: false, + conditionalField: '', + }, + validators: { + onChange: ({ value }) => { + if (value.showField && !value.conditionalField) { + return { + fields: { + conditionalField: 'Conditional field is required when shown', + }, + } + } + return undefined + }, + }, + }) + + form.mount() + + const showFieldApi = new FieldApi({ form, name: 'showField' }) + showFieldApi.mount() + + showFieldApi.setValue(true) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(form.state.fieldMeta.conditionalField).toBeDefined() + expect(form.state.fieldMeta.conditionalField.errorMap.onChange).toBe( + 'Conditional field is required when shown', + ) + + const conditionalField = new FieldApi({ form, name: 'conditionalField' }) + conditionalField.mount() + + expect(conditionalField.state.meta.errors).toContain( + 'Conditional field is required when shown', + ) + + showFieldApi.setValue(false) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + form.deleteField('conditionalField') + + expect(form.fieldInfo.conditionalField).toBeUndefined() + expect(form.state.fieldMeta.conditionalField).toBeUndefined() + }) + }) + + describe('edge cases and error handling', () => { + it('should handle deleteField on non-existent fields gracefully', () => { + const form = new FormApi({ + defaultValues: { + existingField: 'value', + }, + }) + + form.mount() + + expect(() => { + form.deleteField('nonExistentField' as keyof typeof form.state.values) + }).not.toThrow() + + expect(form.state.values.existingField).toBe('value') + }) + + it('should handle concurrent field operations correctly', async () => { + const form = new FormApi({ + defaultValues: { + field1: 'value1', + field2: 'value2', + field3: 'value3', + }, + }) + + form.mount() + + const field1 = new FieldApi({ form, name: 'field1' }) + const field2 = new FieldApi({ form, name: 'field2' }) + const field3 = new FieldApi({ form, name: 'field3' }) + + field1.mount() + field2.mount() + field3.mount() + + const operations = [ + () => form.deleteField('field1'), + () => form.deleteField('field2'), + () => form.setFieldValue('field3', 'new value'), + ] + + await Promise.all(operations.map((op) => Promise.resolve(op()))) + + expect(form.fieldInfo.field1).toBeUndefined() + expect(form.fieldInfo.field2).toBeUndefined() + expect(form.fieldInfo.field3).toBeDefined() + expect(form.state.values.field3).toBe('new value') + }) + }) }) diff --git a/packages/form-core/tests/FieldInfo-management.test.ts b/packages/form-core/tests/FieldInfo-management.test.ts deleted file mode 100644 index ea10a5a1a..000000000 --- a/packages/form-core/tests/FieldInfo-management.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { FieldApi, FormApi } from '../src' - -describe('FieldInfo Management for PR #1691', () => { - describe('Form validation with delayed field mounting', () => { - it('should auto-create fieldInfo entries for fields with validation errors', async () => { - const form = new FormApi({ - defaultValues: { - existingField: '', - delayedField: '', - }, - validators: { - onMount: ({ value }) => { - const errors: Record = {} - if (!value.existingField) { - errors.existingField = 'Existing field is required' - } - if (!value.delayedField) { - errors.delayedField = 'Delayed field is required' - } - return { fields: errors } - }, - }, - }) - - form.mount() - - const existingField = new FieldApi({ - form, - name: 'existingField', - }) - existingField.mount() - - await new Promise((resolve) => setTimeout(resolve, 100)) - - expect(form.fieldInfo.delayedField).toBeDefined() - expect(form.fieldInfo.delayedField.instance).toBeNull() - - const delayedField = new FieldApi({ - form, - name: 'delayedField', - }) - delayedField.mount() - - expect(delayedField.state.meta.errors).toContain( - 'Delayed field is required', - ) - }) - - it('should handle multiple delayed fields with different error types', async () => { - const form = new FormApi({ - defaultValues: { - field1: '', - field2: '', - field3: '', - }, - validators: { - onMount: () => { - return { - fields: { - field1: 'Field 1 error', - field2: 'Field 2 error', - field3: 'Field 3 error', - }, - } - }, - }, - }) - - form.mount() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(form.fieldInfo.field1).toBeDefined() - expect(form.fieldInfo.field2).toBeDefined() - expect(form.fieldInfo.field3).toBeDefined() - - const field1 = new FieldApi({ form, name: 'field1' }) - field1.mount() - - await new Promise((resolve) => setTimeout(resolve, 25)) - - const field2 = new FieldApi({ form, name: 'field2' }) - field2.mount() - - await new Promise((resolve) => setTimeout(resolve, 25)) - - const field3 = new FieldApi({ form, name: 'field3' }) - field3.mount() - - expect(field1.state.meta.errors).toContain('Field 1 error') - expect(field2.state.meta.errors).toContain('Field 2 error') - expect(field3.state.meta.errors).toContain('Field 3 error') - }) - }) - - describe('deleteField functionality', () => { - it('should remove field from fieldInfo and _allFieldErrors', () => { - const form = new FormApi({ - defaultValues: { - fieldToDelete: 'test', - keepField: 'keep', - }, - validators: { - onMount: () => { - return { - fields: { - fieldToDelete: 'Field error', - keepField: 'Keep field error', - }, - } - }, - }, - }) - - form.mount() - - const field = new FieldApi({ - form, - name: 'fieldToDelete', - }) - field.mount() - - expect(form.fieldInfo.fieldToDelete).toBeDefined() - expect(form.state._allFieldErrors?.fieldToDelete).toBeDefined() - - form.deleteField('fieldToDelete') - - expect(form.fieldInfo.fieldToDelete).toBeUndefined() - expect(form.state._allFieldErrors?.fieldToDelete).toBeUndefined() - - expect(form.fieldInfo.keepField).toBeDefined() - expect(form.state._allFieldErrors?.keepField).toBeDefined() - }) - - it('should remove nested fields when parent is deleted', () => { - const form = new FormApi({ - defaultValues: { - parent: { - child1: 'value1', - child2: 'value2', - }, - otherField: 'other', - }, - }) - - form.mount() - - const parentField = new FieldApi({ form, name: 'parent' }) - const child1Field = new FieldApi({ form, name: 'parent.child1' }) - const child2Field = new FieldApi({ form, name: 'parent.child2' }) - const otherField = new FieldApi({ form, name: 'otherField' }) - - parentField.mount() - child1Field.mount() - child2Field.mount() - otherField.mount() - - expect(form.fieldInfo.parent).toBeDefined() - expect(form.fieldInfo['parent.child1']).toBeDefined() - expect(form.fieldInfo['parent.child2']).toBeDefined() - expect(form.fieldInfo.otherField).toBeDefined() - - form.deleteField('parent') - - expect(form.fieldInfo.parent).toBeUndefined() - expect(form.fieldInfo['parent.child1']).toBeUndefined() - expect(form.fieldInfo['parent.child2']).toBeUndefined() - - expect(form.fieldInfo.otherField).toBeDefined() - }) - }) - - describe('removeValue functionality', () => { - it('should remove field value and clean up fieldInfo entry', () => { - const form = new FormApi({ - defaultValues: { - fieldToRemove: 'initial value', - keepField: 'keep value', - }, - }) - - form.mount() - - const field = new FieldApi({ - form, - name: 'fieldToRemove', - }) - const keepField = new FieldApi({ - form, - name: 'keepField', - }) - field.mount() - keepField.mount() - - expect(form.state.values.fieldToRemove).toBe('initial value') - expect(form.fieldInfo.fieldToRemove).toBeDefined() - - form.deleteField('fieldToRemove') - - expect(form.state.values.fieldToRemove).toBeUndefined() - - const fieldInfoKeys = Object.keys(form.fieldInfo) - expect(fieldInfoKeys.includes('fieldToRemove')).toBe(false) - - expect(form.state.values.keepField).toBe('keep value') - expect(form.fieldInfo.keepField).toBeDefined() - }) - - it('should remove field from _allFieldErrors when deleteField is called', () => { - const form = new FormApi({ - defaultValues: { - fieldWithError: '', - otherField: '', - }, - validators: { - onMount: () => { - return { - fields: { - fieldWithError: 'Field error', - otherField: 'Other error', - }, - } - }, - }, - }) - - form.mount() - - const field = new FieldApi({ - form, - name: 'fieldWithError', - }) - field.mount() - - expect(form.state._allFieldErrors?.fieldWithError).toBeDefined() - expect(form.state._allFieldErrors?.otherField).toBeDefined() - - form.deleteField('fieldWithError') - - expect(form.state._allFieldErrors?.fieldWithError).toBeUndefined() - - expect(form.state._allFieldErrors?.otherField).toBeDefined() - }) - }) - - describe('Dynamic field management', () => { - it('should handle dynamic addition and removal of fields', () => { - const form = new FormApi({ - defaultValues: { - dynamicFields: [] as string[], - }, - }) - - form.mount() - - form.setFieldValue('dynamicFields', ['field1', 'field2', 'field3']) - - const field1 = new FieldApi({ form, name: 'dynamicFields[0]' }) - const field2 = new FieldApi({ form, name: 'dynamicFields[1]' }) - const field3 = new FieldApi({ form, name: 'dynamicFields[2]' }) - - field1.mount() - field2.mount() - field3.mount() - - expect(form.fieldInfo['dynamicFields[0]']).toBeDefined() - expect(form.fieldInfo['dynamicFields[1]']).toBeDefined() - expect(form.fieldInfo['dynamicFields[2]']).toBeDefined() - - form.deleteField('dynamicFields[1]') - form.deleteField('dynamicFields[2]') - - const fieldInfoKeys = Object.keys(form.fieldInfo) - expect(fieldInfoKeys.includes('dynamicFields[1]')).toBe(false) - expect(fieldInfoKeys.includes('dynamicFields[2]')).toBe(false) - - expect(form.fieldInfo['dynamicFields[0]']).toBeDefined() - }) - - it('should maintain validation state consistency during field lifecycle', async () => { - const form = new FormApi({ - defaultValues: { - showField: false, - conditionalField: '', - }, - validators: { - onChange: ({ value }) => { - if (value.showField && !value.conditionalField) { - return { - fields: { - conditionalField: 'Conditional field is required when shown', - }, - } - } - return undefined - }, - }, - }) - - form.mount() - - const showFieldApi = new FieldApi({ form, name: 'showField' }) - showFieldApi.mount() - - showFieldApi.setValue(true) - - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(form.fieldInfo.conditionalField).toBeDefined() - expect(form.state._allFieldErrors?.conditionalField).toBeDefined() - - const conditionalField = new FieldApi({ form, name: 'conditionalField' }) - conditionalField.mount() - - expect(conditionalField.state.meta.errors).toContain( - 'Conditional field is required when shown', - ) - - showFieldApi.setValue(false) - - await new Promise((resolve) => setTimeout(resolve, 50)) - - form.deleteField('conditionalField') - - expect(form.fieldInfo.conditionalField).toBeUndefined() - expect(form.state._allFieldErrors?.conditionalField).toBeUndefined() - }) - }) - - describe('Edge cases and error handling', () => { - it('should handle deleteField on non-existent fields gracefully', () => { - const form = new FormApi({ - defaultValues: { - existingField: 'value', - }, - }) - - form.mount() - - expect(() => { - form.deleteField('nonExistentField' as keyof typeof form.state.values) - }).not.toThrow() - - expect(form.state.values.existingField).toBe('value') - }) - - it('should handle concurrent field operations correctly', async () => { - const form = new FormApi({ - defaultValues: { - field1: 'value1', - field2: 'value2', - field3: 'value3', - }, - }) - - form.mount() - - const field1 = new FieldApi({ form, name: 'field1' }) - const field2 = new FieldApi({ form, name: 'field2' }) - const field3 = new FieldApi({ form, name: 'field3' }) - - field1.mount() - field2.mount() - field3.mount() - - const operations = [ - () => form.deleteField('field1'), - () => form.deleteField('field2'), - () => form.setFieldValue('field3', 'new value'), - ] - - await Promise.all(operations.map((op) => Promise.resolve(op()))) - - expect(form.fieldInfo.field1).toBeUndefined() - expect(form.fieldInfo.field2).toBeUndefined() - expect(form.fieldInfo.field3).toBeDefined() - expect(form.state.values.field3).toBe('new value') - }) - }) -}) diff --git a/packages/form-core/tests/hidden-field-mount.test-d.ts b/packages/form-core/tests/hidden-field-mount.test-d.ts deleted file mode 100644 index 0b46c08aa..000000000 --- a/packages/form-core/tests/hidden-field-mount.test-d.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { FieldApi, FormApi } from '../src' - -describe('Hidden field mount validation', () => { - it('should display errors on delayed field mount', async () => { - const form = new FormApi({ - defaultValues: { - hiddenField: '', - visibleField: '', - }, - validators: { - onMount: ({ value }) => { - const errors: Record = {} - if (!value.hiddenField) { - errors.hiddenField = 'Hidden field is required' - } - if (!value.visibleField) { - errors.visibleField = 'Visible field is required' - } - return { fields: errors } - }, - }, - }) - - form.mount() - - const visibleField = new FieldApi({ - form, - name: 'visibleField', - }) - visibleField.mount() - - await new Promise((resolve) => setTimeout(resolve, 100)) - - const hiddenField = new FieldApi({ - form, - name: 'hiddenField', - }) - hiddenField.mount() - - expect(hiddenField.state.meta.errors).toContain('Hidden field is required') - - expect(visibleField.state.meta.errors).toContain( - 'Visible field is required', - ) - }) - - it('should sync errors for multiple delayed fields', async () => { - const form = new FormApi({ - defaultValues: { - field1: '', - field2: '', - field3: '', - }, - validators: { - onMount: () => { - return { - fields: { - field1: 'Field 1 error', - field2: 'Field 2 error', - field3: 'Field 3 error', - }, - } - }, - }, - }) - - form.mount() - - const field1 = new FieldApi({ form, name: 'field1' }) - field1.mount() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - const field2 = new FieldApi({ form, name: 'field2' }) - field2.mount() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - const field3 = new FieldApi({ form, name: 'field3' }) - field3.mount() - - expect(field1.state.meta.errors).toContain('Field 1 error') - expect(field2.state.meta.errors).toContain('Field 2 error') - expect(field3.state.meta.errors).toContain('Field 3 error') - }) - - it('should handle field remount scenarios', () => { - const form = new FormApi({ - defaultValues: { - remountField: '', - }, - validators: { - onMount: ({ value }) => { - if (!value.remountField) { - return { fields: { remountField: 'Remount field is required' } } - } - return undefined - }, - }, - }) - - form.mount() - - const field = new FieldApi({ form, name: 'remountField' }) - const cleanup1 = field.mount() - - expect(field.state.meta.errors).toContain('Remount field is required') - - cleanup1() - - const cleanup2 = field.mount() - expect(field.state.meta.errors).toContain('Remount field is required') - - cleanup2() - }) -}) From 5dd2a675ccb188f1ff7f224635d8bd74a4e68e88 Mon Sep 17 00:00:00 2001 From: jiji-hoon96 Date: Fri, 24 Oct 2025 00:43:38 +0900 Subject: [PATCH 08/16] refactor: replace setTimeout with vi.useFakeTimers and sleep utility in field tests --- packages/form-core/tests/FieldApi.spec.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 0122d710a..8e9d223ec 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2503,6 +2503,7 @@ describe('field api', () => { describe('delayed field mounting', () => { it('should display validation errors on fields mounted after form validation', async () => { + vi.useFakeTimers() const form = new FormApi({ defaultValues: { existingField: '', @@ -2530,7 +2531,7 @@ describe('field api', () => { }) existingField.mount() - await new Promise((resolve) => setTimeout(resolve, 100)) + await sleep(100) expect(form.state.fieldMeta.delayedField).toBeDefined() expect(form.state.fieldMeta.delayedField.errorMap.onMount).toBe( @@ -2546,9 +2547,11 @@ describe('field api', () => { expect(delayedField.state.meta.errors).toContain( 'Delayed field is required', ) + vi.useRealTimers() }) it('should handle multiple delayed fields with different error types', async () => { + vi.useFakeTimers() const form = new FormApi({ defaultValues: { field1: '', @@ -2570,7 +2573,7 @@ describe('field api', () => { form.mount() - await new Promise((resolve) => setTimeout(resolve, 50)) + await sleep(50) // All fields should have fieldMeta with errors expect(form.state.fieldMeta.field1).toBeDefined() @@ -2580,12 +2583,12 @@ describe('field api', () => { const field1 = new FieldApi({ form, name: 'field1' }) field1.mount() - await new Promise((resolve) => setTimeout(resolve, 25)) + await sleep(25) const field2 = new FieldApi({ form, name: 'field2' }) field2.mount() - await new Promise((resolve) => setTimeout(resolve, 25)) + await sleep(25) const field3 = new FieldApi({ form, name: 'field3' }) field3.mount() @@ -2593,6 +2596,7 @@ describe('field api', () => { expect(field1.state.meta.errors).toContain('Field 1 error') expect(field2.state.meta.errors).toContain('Field 2 error') expect(field3.state.meta.errors).toContain('Field 3 error') + vi.useRealTimers() }) }) @@ -2779,6 +2783,7 @@ describe('field api', () => { }) it('should maintain validation state consistency during field lifecycle', async () => { + vi.useFakeTimers() const form = new FormApi({ defaultValues: { showField: false, @@ -2805,7 +2810,7 @@ describe('field api', () => { showFieldApi.setValue(true) - await new Promise((resolve) => setTimeout(resolve, 50)) + await sleep(50) expect(form.state.fieldMeta.conditionalField).toBeDefined() expect(form.state.fieldMeta.conditionalField.errorMap.onChange).toBe( @@ -2821,12 +2826,13 @@ describe('field api', () => { showFieldApi.setValue(false) - await new Promise((resolve) => setTimeout(resolve, 50)) + await sleep(50) form.deleteField('conditionalField') expect(form.fieldInfo.conditionalField).toBeUndefined() expect(form.state.fieldMeta.conditionalField).toBeUndefined() + vi.useRealTimers() }) }) From 8ab46feae1ac8a90cbc0e6ec1cf54563c69afd15 Mon Sep 17 00:00:00 2001 From: jiji-hoon96 Date: Fri, 24 Oct 2025 00:50:18 +0900 Subject: [PATCH 09/16] fix: handle undefined field metadata gracefully in form validation --- packages/form-core/src/FormApi.ts | 16 ++++------- packages/form-core/tests/FieldApi.spec.ts | 33 +++++++++++------------ 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 0f27cec23..cb4ff1b4f 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1538,18 +1538,12 @@ export class FormApi< ] as DeepKeys[]) for (const field of allFieldsToProcess) { - // Create fieldMeta for fields with errors if they don't exist yet - if (fieldErrors?.[field] && !this.state.fieldMeta[field]) { - this.setFieldMeta(field, () => defaultFieldMeta) - } - const fieldMeta = this.getFieldMeta(field) - if (!fieldMeta) continue - + const { errorMap: currentErrorMap, errorSourceMap: currentErrorMapSource, - } = fieldMeta + } = fieldMeta || { errorMap: {}, errorSourceMap: {} } const newFormValidatorError = fieldErrors?.[field] @@ -1575,13 +1569,13 @@ export class FormApi< currentErrorMap?.[errorMapKey] !== newErrorValue ) { this.setFieldMeta(field, (prev) => ({ - ...prev, + ...(prev || defaultFieldMeta), errorMap: { - ...prev.errorMap, + ...(prev?.errorMap || {}), [errorMapKey]: newErrorValue, }, errorSourceMap: { - ...prev.errorSourceMap, + ...(prev?.errorSourceMap || {}), [errorMapKey]: newSource, }, })) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 8e9d223ec..bf4c065df 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2531,7 +2531,7 @@ describe('field api', () => { }) existingField.mount() - await sleep(100) + await vi.advanceTimersByTimeAsync(100) expect(form.state.fieldMeta.delayedField).toBeDefined() expect(form.state.fieldMeta.delayedField.errorMap.onMount).toBe( @@ -2573,7 +2573,7 @@ describe('field api', () => { form.mount() - await sleep(50) + await vi.advanceTimersByTimeAsync(50) // All fields should have fieldMeta with errors expect(form.state.fieldMeta.field1).toBeDefined() @@ -2583,12 +2583,12 @@ describe('field api', () => { const field1 = new FieldApi({ form, name: 'field1' }) field1.mount() - await sleep(25) + await vi.advanceTimersByTimeAsync(25) const field2 = new FieldApi({ form, name: 'field2' }) field2.mount() - await sleep(25) + await vi.advanceTimersByTimeAsync(25) const field3 = new FieldApi({ form, name: 'field3' }) field3.mount() @@ -2628,15 +2628,14 @@ describe('field api', () => { field.mount() expect(form.fieldInfo.fieldToDelete).toBeDefined() - expect(form.state.fieldMeta.fieldToDelete).toBeDefined() + expect(field.state.meta.errors).toContain('Field error') form.deleteField('fieldToDelete') expect(form.fieldInfo.fieldToDelete).toBeUndefined() - expect(form.state.fieldMeta.fieldToDelete).toBeUndefined() + expect(form.state.values.fieldToDelete).toBeUndefined() expect(form.fieldInfo.keepField).toBeDefined() - expect(form.state.fieldMeta.keepField).toBeDefined() }) it('should remove nested fields when parent is deleted', () => { @@ -2711,7 +2710,7 @@ describe('field api', () => { expect(form.fieldInfo.keepField).toBeDefined() }) - it('should remove field from fieldMeta when deleteField is called', () => { + it('should remove field errors when deleteField is called', () => { const form = new FormApi({ defaultValues: { fieldWithError: '', @@ -2737,14 +2736,17 @@ describe('field api', () => { }) field.mount() - expect(form.state.fieldMeta.fieldWithError).toBeDefined() - expect(form.state.fieldMeta.otherField).toBeDefined() + expect(field.state.meta.errors).toContain('Field error') form.deleteField('fieldWithError') - expect(form.state.fieldMeta.fieldWithError).toBeUndefined() + expect(form.state.values.fieldWithError).toBeUndefined() + expect(form.fieldInfo.fieldWithError).toBeUndefined() - expect(form.state.fieldMeta.otherField).toBeDefined() + // Other field should still have its error + const otherField = new FieldApi({ form, name: 'otherField' }) + otherField.mount() + expect(otherField.state.meta.errors).toContain('Other error') }) }) @@ -2810,7 +2812,7 @@ describe('field api', () => { showFieldApi.setValue(true) - await sleep(50) + await vi.advanceTimersByTimeAsync(50) expect(form.state.fieldMeta.conditionalField).toBeDefined() expect(form.state.fieldMeta.conditionalField.errorMap.onChange).toBe( @@ -2824,14 +2826,9 @@ describe('field api', () => { 'Conditional field is required when shown', ) - showFieldApi.setValue(false) - - await sleep(50) - form.deleteField('conditionalField') expect(form.fieldInfo.conditionalField).toBeUndefined() - expect(form.state.fieldMeta.conditionalField).toBeUndefined() vi.useRealTimers() }) }) From 1a2005a4460b0f1934975e589e9478a07f2a1221 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:58:08 +0000 Subject: [PATCH 10/16] ci: apply automated fixes and generate docs --- packages/form-core/src/FormApi.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 55c626cdf..482034fb5 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1669,12 +1669,15 @@ export class FormApi< ] as DeepKeys[]) for (const field of allFieldsToProcess) { - if (this.baseStore.state.fieldMetaBase[field] === undefined && !fieldErrors?.[field]) { + if ( + this.baseStore.state.fieldMetaBase[field] === undefined && + !fieldErrors?.[field] + ) { continue } const fieldMeta = this.getFieldMeta(field) - + const { errorMap: currentErrorMap, errorSourceMap: currentErrorMapSource, From f8341e1d690fbb3772292accee0eb560b014c96b Mon Sep 17 00:00:00 2001 From: jiji-hoon96 Date: Fri, 24 Oct 2025 01:00:20 +0900 Subject: [PATCH 11/16] fix: add optional chaining for fieldMeta after upstream merge --- packages/form-core/tests/FieldApi.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index bf4c065df..5fc0b49b0 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2534,7 +2534,7 @@ describe('field api', () => { await vi.advanceTimersByTimeAsync(100) expect(form.state.fieldMeta.delayedField).toBeDefined() - expect(form.state.fieldMeta.delayedField.errorMap.onMount).toBe( + expect(form.state.fieldMeta.delayedField?.errorMap.onMount).toBe( 'Delayed field is required', ) @@ -2815,7 +2815,7 @@ describe('field api', () => { await vi.advanceTimersByTimeAsync(50) expect(form.state.fieldMeta.conditionalField).toBeDefined() - expect(form.state.fieldMeta.conditionalField.errorMap.onChange).toBe( + expect(form.state.fieldMeta.conditionalField?.errorMap.onChange).toBe( 'Conditional field is required when shown', ) From 3242a9ee378c85d48b26d2e3ba3aa1136ca96cbb Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Fri, 24 Oct 2025 09:59:28 +0900 Subject: [PATCH 12/16] refactor: simplify form error handling logic --- packages/form-core/src/FormApi.ts | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 482034fb5..2aa1fa75c 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1676,12 +1676,9 @@ export class FormApi< continue } - const fieldMeta = this.getFieldMeta(field) - - const { - errorMap: currentErrorMap, - errorSourceMap: currentErrorMapSource, - } = fieldMeta || { errorMap: {}, errorSourceMap: {} } + const fieldMeta = this.getFieldMeta(field) ?? defaultFieldMeta + const { errorMap: currentErrorMap, errorSourceMap: currentErrorMapSource } = + fieldMeta const newFormValidatorError = fieldErrors?.[field] @@ -1689,10 +1686,8 @@ export class FormApi< determineFormLevelErrorSourceAndValue({ newFormValidatorError, isPreviousErrorFromFormValidator: - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - currentErrorMapSource?.[errorMapKey] === 'form', - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - previousErrorValue: currentErrorMap?.[errorMapKey], + currentErrorMapSource[errorMapKey] === 'form', + previousErrorValue: currentErrorMap[errorMapKey], }) if (newSource === 'form') { @@ -1702,18 +1697,15 @@ export class FormApi< } } - if ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - currentErrorMap?.[errorMapKey] !== newErrorValue - ) { - this.setFieldMeta(field, (prev) => ({ - ...(prev || defaultFieldMeta), + if (currentErrorMap[errorMapKey] !== newErrorValue) { + this.setFieldMeta(field, (prev = defaultFieldMeta) => ({ + ...prev, errorMap: { - ...(prev?.errorMap || {}), + ...prev.errorMap, [errorMapKey]: newErrorValue, }, errorSourceMap: { - ...(prev?.errorSourceMap || {}), + ...prev.errorSourceMap, [errorMapKey]: newSource, }, })) From 2289eae024af22b925c77a5344a822e041f1191f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 01:00:23 +0000 Subject: [PATCH 13/16] ci: apply automated fixes and generate docs --- packages/form-core/src/FormApi.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 2aa1fa75c..934833c46 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1677,8 +1677,10 @@ export class FormApi< } const fieldMeta = this.getFieldMeta(field) ?? defaultFieldMeta - const { errorMap: currentErrorMap, errorSourceMap: currentErrorMapSource } = - fieldMeta + const { + errorMap: currentErrorMap, + errorSourceMap: currentErrorMapSource, + } = fieldMeta const newFormValidatorError = fieldErrors?.[field] From 1442f51dd68b5920eb1f10ce01c1e7b561a31e7c Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:00:44 +0100 Subject: [PATCH 14/16] Add changeset --- .changeset/ninety-trainers-pull.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ninety-trainers-pull.md diff --git a/.changeset/ninety-trainers-pull.md b/.changeset/ninety-trainers-pull.md new file mode 100644 index 000000000..aee219adb --- /dev/null +++ b/.changeset/ninety-trainers-pull.md @@ -0,0 +1,5 @@ +--- +"@tanstack/form-core": patch +--- + +Ensure dynamically rendered fields receive form validation errors From f670c51c8751ed6422eec21cf2ea41a20b00c063 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:06:26 +0100 Subject: [PATCH 15/16] chore: fix wrong merge resolution --- packages/form-core/tests/FieldApi.spec.ts | 79 ++++++++++++----------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 74bbc1240..abca29868 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2872,56 +2872,57 @@ describe('field api', () => { vi.useRealTimers() }) }) +}) - describe('edge cases and error handling', () => { - it('should handle deleteField on non-existent fields gracefully', () => { - const form = new FormApi({ - defaultValues: { - existingField: 'value', - }, - }) +describe('edge cases and error handling', () => { + it('should handle deleteField on non-existent fields gracefully', () => { + const form = new FormApi({ + defaultValues: { + existingField: 'value', + }, + }) - form.mount() + form.mount() - expect(() => { - form.deleteField('nonExistentField' as keyof typeof form.state.values) - }).not.toThrow() + expect(() => { + form.deleteField('nonExistentField' as keyof typeof form.state.values) + }).not.toThrow() - expect(form.state.values.existingField).toBe('value') - }) + expect(form.state.values.existingField).toBe('value') + }) - it('should handle concurrent field operations correctly', async () => { - const form = new FormApi({ - defaultValues: { - field1: 'value1', - field2: 'value2', - field3: 'value3', - }, - }) + it('should handle concurrent field operations correctly', async () => { + const form = new FormApi({ + defaultValues: { + field1: 'value1', + field2: 'value2', + field3: 'value3', + }, + }) - form.mount() + form.mount() - const field1 = new FieldApi({ form, name: 'field1' }) - const field2 = new FieldApi({ form, name: 'field2' }) - const field3 = new FieldApi({ form, name: 'field3' }) + const field1 = new FieldApi({ form, name: 'field1' }) + const field2 = new FieldApi({ form, name: 'field2' }) + const field3 = new FieldApi({ form, name: 'field3' }) - field1.mount() - field2.mount() - field3.mount() + field1.mount() + field2.mount() + field3.mount() - const operations = [ - () => form.deleteField('field1'), - () => form.deleteField('field2'), - () => form.setFieldValue('field3', 'new value'), - ] + const operations = [ + () => form.deleteField('field1'), + () => form.deleteField('field2'), + () => form.setFieldValue('field3', 'new value'), + ] - await Promise.all(operations.map((op) => Promise.resolve(op()))) + await Promise.all(operations.map((op) => Promise.resolve(op()))) - expect(form.fieldInfo.field1).toBeUndefined() - expect(form.fieldInfo.field2).toBeUndefined() - expect(form.fieldInfo.field3).toBeDefined() - expect(form.state.values.field3).toBe('new value') - }) + expect(form.fieldInfo.field1).toBeUndefined() + expect(form.fieldInfo.field2).toBeUndefined() + expect(form.fieldInfo.field3).toBeDefined() + expect(form.state.values.field3).toBe('new value') + }) it('should allow setting to explicitly undefined', () => { const form = new FormApi({ defaultValues: { a: '' as string | undefined }, From 23bfa578ed45ee64dc2544aa74fa988ddce95c4c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:07:19 +0000 Subject: [PATCH 16/16] ci: apply automated fixes and generate docs --- .changeset/ninety-trainers-pull.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ninety-trainers-pull.md b/.changeset/ninety-trainers-pull.md index aee219adb..6c9e7f34e 100644 --- a/.changeset/ninety-trainers-pull.md +++ b/.changeset/ninety-trainers-pull.md @@ -1,5 +1,5 @@ --- -"@tanstack/form-core": patch +'@tanstack/form-core': patch --- Ensure dynamically rendered fields receive form validation errors