From 7a1314e34e328f56d131a33b8d7a799a3d87b997 Mon Sep 17 00:00:00 2001 From: Alexander Kireev Date: Fri, 26 Jun 2026 18:30:47 +0700 Subject: [PATCH] fix(form-core): don't auto-touch shifted siblings on array mutation validateArrayFieldsStartingFrom re-validates the array items shifted by an insert/remove/replace. It went through validateField, which touches each field as a side-effect, so siblings the user never interacted with were flipped to isTouched: true. The shifted fields still need validating (their value changed), so keep calling validateField, but restore the prior touched state for fields that weren't touched before the call. Closes #2131 --- packages/form-core/src/FormApi.ts | 19 ++++++++++- .../form-core/tests/FieldGroupApi.spec.ts | 16 ++++++--- packages/form-core/tests/FormApi.spec.ts | 34 +++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 5c9de3c1c..67cc5644d 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1917,7 +1917,24 @@ export class FormApi< batch(() => { fieldsToValidate.forEach((nestedField) => { fieldValidationPromises.push( - Promise.resolve().then(() => this.validateField(nestedField, cause)), + Promise.resolve().then(() => { + // These fields were merely shifted by an array mutation, the user + // never interacted with them. Their value did change, so they + // still need to be validated, but `validateField` auto-touches as + // a side-effect — which would spuriously mark untouched siblings + // as touched. Remember the prior touched state and restore it for + // fields that weren't touched before. + const fieldInstance = this.fieldInfo[nestedField]?.instance + const wasTouched = fieldInstance?.store.state.meta.isTouched ?? true + + const result = this.validateField(nestedField, cause) + + if (fieldInstance && !wasTouched) { + fieldInstance.setMeta((prev) => ({ ...prev, isTouched: false })) + } + + return result + }), ) }) }) diff --git a/packages/form-core/tests/FieldGroupApi.spec.ts b/packages/form-core/tests/FieldGroupApi.spec.ts index 2ff20790d..88b9368c9 100644 --- a/packages/form-core/tests/FieldGroupApi.spec.ts +++ b/packages/form-core/tests/FieldGroupApi.spec.ts @@ -401,6 +401,9 @@ describe('field group api', () => { const field1 = new FieldApi({ form, name: 'people.names[1].name', + validators: { + onChange: () => 'validated', + }, }) const field2 = new FieldApi({ form, @@ -423,9 +426,13 @@ describe('field group api', () => { await vi.runAllTimersAsync() + // The call was forwarded to the form and validation reached the shifted + // field (field1's validator ran), but validating a shifted field must not + // mark it — or its untouched siblings — as touched (issue #2131). + expect(field1.getMeta().errors).toStrictEqual(['validated']) expect(field0.getMeta().isTouched).toBe(false) - expect(field1.getMeta().isTouched).toBe(true) - expect(field2.getMeta().isTouched).toBe(true) + expect(field1.getMeta().isTouched).toBe(false) + expect(field2.getMeta().isTouched).toBe(false) }) it('should forward handleSubmit to the form', async () => { @@ -562,9 +569,10 @@ describe('field group api', () => { await vi.runAllTimersAsync() + // Shifted fields must not be marked as touched (issue #2131) expect(field0.getMeta().isTouched).toBe(false) - expect(field1.getMeta().isTouched).toBe(true) - expect(field2.getMeta().isTouched).toBe(true) + expect(field1.getMeta().isTouched).toBe(false) + expect(field2.getMeta().isTouched).toBe(false) fieldGroup.pushFieldValue('names', { name: 'Push' }) diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 092d0ed6c..138865f08 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -682,6 +682,40 @@ describe('form api', () => { expect(field3.state.meta.errors).toStrictEqual([]) }) + it('should not mark shifted siblings as touched when removing array values', async () => { + const form = new FormApi({ + defaultValues: { + names: ['a', 'b', 'c', 'd', 'e'], + }, + }) + form.mount() + // Since validation runs through the field, a field must be mounted for that array + new FieldApi({ form, name: 'names' }).mount() + + const fields = [0, 1, 2, 3, 4].map((i) => { + const field = new FieldApi({ form, name: `names[${i}]` as const }) + field.mount() + return field + }) + + // The user never interacts with any field + expect(fields.map((f) => f.state.meta.isTouched)).toStrictEqual([ + false, + false, + false, + false, + false, + ]) + + await form.removeFieldValue('names', 2) + + expect(form.getFieldValue('names')).toStrictEqual(['a', 'b', 'd', 'e']) + // Removing a value must not touch the siblings that merely shifted + expect(fields.slice(0, 4).map((f) => f.state.meta.isTouched)).toStrictEqual( + [false, false, false, false], + ) + }) + it('should shift meta (nested) when removing array values', async () => { const form = new FormApi({ defaultValues: {