diff --git a/.changeset/complete-unmounted-field-meta.md b/.changeset/complete-unmounted-field-meta.md new file mode 100644 index 0000000000..483c905d44 --- /dev/null +++ b/.changeset/complete-unmounted-field-meta.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Fix `form.setFieldValue()` creating incomplete metadata for a field that is not mounted yet. A `FormGroup` validator that later reports an error for that field no longer throws `TypeError: Cannot read properties of undefined (reading 'onChange')`, and the error is assigned to the field. diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 4400017d30..63259e6470 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -2659,13 +2659,12 @@ export class FormApi< batch(() => { if (!dontUpdateMeta) { - this.setFieldMeta(field, (prev) => ({ + this.setFieldMeta(field, (prev = defaultFieldMeta) => ({ ...prev, isTouched: true, isDirty: true, errorMap: { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - ...prev?.errorMap, + ...prev.errorMap, onMount: undefined, }, })) diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index c57bb044e8..858c489ded 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -279,6 +279,63 @@ describe('form api', () => { expect(form.state.isPristine).toBe(false) }) + it('should create complete field meta when setting the value of an unmounted field', () => { + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + form.mount() + + form.setFieldValue('name', 'other') + + expect(form.getFieldMeta('name')).toMatchObject({ + isTouched: true, + isDirty: true, + isBlurred: false, + isValidating: false, + errorMap: { onMount: undefined }, + errorSourceMap: {}, + errors: [], + }) + }) + + it('should preserve existing field meta when setting a field value', () => { + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + validators: { + onMount: () => 'mount error', + }, + }) + field.mount() + + form.setFieldMeta('name', (prev) => ({ + ...prev, + isBlurred: true, + errorMap: { ...prev.errorMap, onBlur: 'blur error' }, + errorSourceMap: { ...prev.errorSourceMap, onBlur: 'field' }, + })) + + form.setFieldValue('name', 'other') + + const meta = form.getFieldMeta('name') + expect(meta?.isBlurred).toBe(true) + expect(meta?.errorMap.onMount).toBeUndefined() + expect(meta?.errorMap.onBlur).toBe('blur error') + expect(meta?.errorSourceMap).toMatchObject({ + onMount: 'field', + onBlur: 'field', + }) + }) + it('should be clean again after being reset from a dirty state', () => { const form = new FormApi({ defaultValues: { diff --git a/packages/form-core/tests/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi.spec.ts index 02d684fc1b..d24494dcf6 100644 --- a/packages/form-core/tests/FormGroupApi.spec.ts +++ b/packages/form-core/tests/FormGroupApi.spec.ts @@ -585,6 +585,61 @@ describe('form group api', () => { ) }) + it('Should distribute group field errors to an unmounted field previously written with setFieldValue', () => { + const form = new FormApi({ + defaultValues: { + launch: { + type: 'manual', + price: undefined as number | undefined, + }, + }, + }) + form.mount() + + const group = new FormGroupApi({ + form, + name: 'launch', + validators: { + onChange: ({ value }) => + value.type === 'exclusive' && value.price === undefined + ? { fields: { price: 'Price is required' } } + : undefined, + }, + }) + group.mount() + + const typeField = new FieldApi({ + form, + name: 'launch.type', + }) + typeField.mount() + + form.setFieldValue('launch.price', undefined) + + expect(() => typeField.handleChange('exclusive')).not.toThrow() + + expect(form.getFieldMeta('launch.price')?.errorMap.onChange).toBe( + 'Price is required', + ) + expect(form.getFieldMeta('launch.price')?.errorSourceMap.onChange).toBe( + 'form', + ) + expect(group.state.meta.isFieldsValid).toBe(false) + + const priceField = new FieldApi({ + form, + name: 'launch.price', + }) + priceField.mount() + + expect(priceField.state.meta.errors).toEqual(['Price is required']) + + priceField.handleChange(10) + + expect(priceField.state.meta.errorMap.onChange).toBeUndefined() + expect(group.state.meta.isFieldsValid).toBe(true) + }) + it('Should propagate manual function field errors from group validators to child fields', async () => { const onSubmit = vi.fn()