diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 7317d70a1..ad5d580c8 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1242,6 +1242,7 @@ export class FieldApi< info.instance = this as never this.update(this.options 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 30f909cfe..934833c46 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1663,16 +1663,20 @@ export class FormApi< const errorMapKey = getErrorMapKey(validateObj.cause) - for (const field of Object.keys( - this.state.fieldMeta, - ) as DeepKeys[]) { - if (this.baseStore.state.fieldMetaBase[field] === undefined) { + const allFieldsToProcess = new Set([ + ...Object.keys(this.state.fieldMeta), + ...Object.keys(fieldErrors || {}), + ] as DeepKeys[]) + + for (const field of allFieldsToProcess) { + if ( + this.baseStore.state.fieldMetaBase[field] === undefined && + !fieldErrors?.[field] + ) { continue } - const fieldMeta = this.getFieldMeta(field) - if (!fieldMeta) continue - + const fieldMeta = this.getFieldMeta(field) ?? defaultFieldMeta const { errorMap: currentErrorMap, errorSourceMap: currentErrorMapSource, @@ -1684,10 +1688,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') { @@ -1697,11 +1699,8 @@ export class FormApi< } } - if ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - currentErrorMap?.[errorMapKey] !== newErrorValue - ) { - this.setFieldMeta(field, (prev) => ({ + if (currentErrorMap[errorMapKey] !== newErrorValue) { + this.setFieldMeta(field, (prev = defaultFieldMeta) => ({ ...prev, errorMap: { ...prev.errorMap, diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 1935e19d4..5fc0b49b0 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2500,4 +2500,387 @@ 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 () => { + vi.useFakeTimers() + 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 vi.advanceTimersByTimeAsync(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', + ) + vi.useRealTimers() + }) + + it('should handle multiple delayed fields with different error types', async () => { + vi.useFakeTimers() + 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 vi.advanceTimersByTimeAsync(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 vi.advanceTimersByTimeAsync(25) + + const field2 = new FieldApi({ form, name: 'field2' }) + field2.mount() + + await vi.advanceTimersByTimeAsync(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') + vi.useRealTimers() + }) + }) + + 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(field.state.meta.errors).toContain('Field error') + + form.deleteField('fieldToDelete') + + expect(form.fieldInfo.fieldToDelete).toBeUndefined() + expect(form.state.values.fieldToDelete).toBeUndefined() + + expect(form.fieldInfo.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 errors 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(field.state.meta.errors).toContain('Field error') + + form.deleteField('fieldWithError') + + expect(form.state.values.fieldWithError).toBeUndefined() + expect(form.fieldInfo.fieldWithError).toBeUndefined() + + // Other field should still have its error + const otherField = new FieldApi({ form, name: 'otherField' }) + otherField.mount() + expect(otherField.state.meta.errors).toContain('Other error') + }) + }) + + 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 () => { + vi.useFakeTimers() + 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 vi.advanceTimersByTimeAsync(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', + ) + + form.deleteField('conditionalField') + + expect(form.fieldInfo.conditionalField).toBeUndefined() + vi.useRealTimers() + }) + }) + + 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') + }) + }) })