Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}),
Comment on lines +1920 to +1937

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

This still runs the auto-touch side effect through validateField.

Line 1930 still calls validateField, and that method sets isTouched = true for mounted fields before validating on Lines 1967-1971. Restoring false afterward only fixes the final flag; it does not prevent touch-gated side effects that can fire during that window (for example the shouldInvalidateOnMount path later in this file). To fully match the PR objective, shifted mounted fields need a validation path that bypasses the touch mutation entirely, plus a regression test that preserves existing on-mount errors after remove/insert/replace.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/form-core/src/FormApi.ts` around lines 1920 - 1937, The
shifted-field validation in FormApi still goes through validateField, which
auto-touches mounted fields and can trigger touch-gated side effects before the
flag is restored. Update the validation flow used for array-shifted fields in
FormApi so it bypasses the isTouched mutation entirely for fields that were not
previously touched, while still running validation and preserving existing
mounted-field error behavior. Use the fieldInstance / wasTouched logic around
validateField as the entry point, and add a regression test covering
remove/insert/replace cases to ensure on-mount errors remain intact without
spuriously touching siblings.

)
})
})
Expand Down
16 changes: 12 additions & 4 deletions packages/form-core/tests/FieldGroupApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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' })

Expand Down
34 changes: 34 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down