Skip to content

fix(form-core): prevent removeValue from auto-touching shifted siblings#2133

Open
mixelburg wants to merge 2 commits intoTanStack:mainfrom
mixelburg:fix/removevalue-touched-siblings
Open

fix(form-core): prevent removeValue from auto-touching shifted siblings#2133
mixelburg wants to merge 2 commits intoTanStack:mainfrom
mixelburg:fix/removevalue-touched-siblings

Conversation

@mixelburg
Copy link
Copy Markdown

@mixelburg mixelburg commented Apr 18, 2026

What changed

In validateArrayFieldsStartingFrom, fields with an existing instance now call fieldInstance.validate(cause) directly instead of going through this.validateField(). This avoids the auto-touch side-effect in validateField that marks fields as touched even when the user never interacted with them.

Why

Calling form.removeValue(index) on an array field was flipping isTouched to true on every sibling at index ≥ removed index. The bug lives in validateField which auto-touches any field that has an instance but isn't yet touched.

How to reproduce

  1. Create a form with an array field
  2. Call form.removeValue(0) without interacting with other fields
  3. Observe that isTouched becomes true on all shifted siblings

Closes #2131

Summary by CodeRabbit

  • Bug Fixes
    • Improved validation behavior for array fields so removing or reordering items no longer incorrectly marks sibling fields as “touched.”
    • Preserves fields' prior touched state when validating already-mounted nested fields, reducing unexpected validation side effects during array operations.

When calling form.removeValue(index) on an array field, isTouched was
incorrectly set to true on every sibling at index >= removed index,
even when the user never interacted with those fields.

The fix bypasses validateField for fields with existing instances and
calls fieldInstance.validate() directly, which preserves validation
without the auto-touch side-effect.

Fixes TanStack#2131
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 18, 2026

📝 Walkthrough

Walkthrough

validateArrayFieldsStartingFrom in FormApi.ts now accepts an optional options?: { avoidTouch?: boolean }. When avoidTouch is true, and a mounted FieldApi instance exists for a nested field, validation is performed via fieldInstance.validate(cause) while preserving prior meta.isTouched state; otherwise it falls back to validateField. removeFieldValue now calls this with { avoidTouch: true } after array validation at 'change'.

Changes

Cohort / File(s) Summary
Array validation & removal
packages/form-core/src/FormApi.ts
Added options?: { avoidTouch?: boolean } to validateArrayFieldsStartingFrom; when avoidTouch is true, prefer fieldInstance.validate(cause) for mounted fields and preserve isTouched during validation. removeFieldValue now calls validateArrayFieldsStartingFrom(..., { avoidTouch: true }) after validating the array at 'change' to avoid touching shifted siblings.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hop through arrays with gentle care,
Avoiding phantom touches in the air,
If a field's mounted, I call it true,
Preserve its state, and nudge not you.
A little hop, a careful test — hooray!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main bug fix: preventing removeValue from auto-touching shifted array field siblings.
Description check ✅ Passed The PR description adequately covers what changed, why it was needed, and how to reproduce the issue, matching the repository's template structure.
Linked Issues check ✅ Passed The code changes directly implement the proposed fix from issue #2131 by calling fieldInstance.validate() directly to avoid the auto-touch behavior in validateField().
Out of Scope Changes check ✅ Passed All changes are scoped to the bug fix: modifications to validateArrayFieldsStartingFrom and removeFieldValue are directly related to preventing unwanted touched state mutations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/form-core/src/FormApi.ts`:
- Around line 1608-1616: The current code calls fieldInstance.validate(cause)
which avoids auto-touching but relies on FieldApi.validate returning [] when
meta.isTouched is false, leaving shifted untouched siblings unvalidated; change
this by adding an internal "validate without touching" path: extend
validateField to accept an options flag (e.g., { touch?: boolean }) or add a new
internal method (e.g., validateFieldInternal/FieldApi.validateWithoutTouch) that
runs the same validation logic regardless of meta.isTouched but does not mutate
touch state, then replace the direct call to fieldInstance.validate(cause) with
a call to validateField(nestedField, cause, { touch: false }) or the new
internal method so shifted siblings revalidate without auto-touch side effects;
update FieldApi.validate to delegate to the shared internal validator to avoid
duplication.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d506b7d4-0546-43d1-a8b3-a5847867e52b

📥 Commits

Reviewing files that changed from the base of the PR and between 254f157 and cd491ba.

📒 Files selected for processing (1)
  • packages/form-core/src/FormApi.ts

Comment thread packages/form-core/src/FormApi.ts Outdated
@nx-cloud
Copy link
Copy Markdown

nx-cloud Bot commented Apr 20, 2026

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit cd491ba

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ❌ Failed 1m 26s View ↗
nx run-many --target=build --exclude=examples/** ✅ Succeeded 34s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-20 05:38:36 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 20, 2026

More templates

@tanstack/angular-form

npm i https://pkg.pr.new/@tanstack/angular-form@2133

@tanstack/form-core

npm i https://pkg.pr.new/@tanstack/form-core@2133

@tanstack/form-devtools

npm i https://pkg.pr.new/@tanstack/form-devtools@2133

@tanstack/lit-form

npm i https://pkg.pr.new/@tanstack/lit-form@2133

@tanstack/react-form

npm i https://pkg.pr.new/@tanstack/react-form@2133

@tanstack/react-form-devtools

npm i https://pkg.pr.new/@tanstack/react-form-devtools@2133

@tanstack/react-form-nextjs

npm i https://pkg.pr.new/@tanstack/react-form-nextjs@2133

@tanstack/react-form-remix

npm i https://pkg.pr.new/@tanstack/react-form-remix@2133

@tanstack/react-form-start

npm i https://pkg.pr.new/@tanstack/react-form-start@2133

@tanstack/solid-form

npm i https://pkg.pr.new/@tanstack/solid-form@2133

@tanstack/solid-form-devtools

npm i https://pkg.pr.new/@tanstack/solid-form-devtools@2133

@tanstack/svelte-form

npm i https://pkg.pr.new/@tanstack/svelte-form@2133

@tanstack/vue-form

npm i https://pkg.pr.new/@tanstack/vue-form@2133

commit: cd491ba

The previous fix bypassed validateField for all calls to
validateArrayFieldsStartingFrom, which broke:
- validateArrayFieldsStartingFrom when called directly (expected isTouched)
- replaceFieldValue (expected errors to appear on validation)
- removeFieldValue (expected errors to appear on validation)

The fix now uses an avoidTouch option that:
1. Only prevents permanently marking fields as touched during removeFieldValue
2. Temporarily sets isTouched=true to allow validation to run (so errors
   are still collected), then restores the original isTouched state
3. All other callers use the default behavior (validateField with touch)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/form-core/src/FormApi.ts (1)

1579-1586: FieldGroupApi.validateArrayFieldsStartingFrom does not forward options.

Per packages/form-core/src/FieldGroupApi.ts:310-324, the FieldGroupApi wrapper delegates to this.form.validateArrayFieldsStartingFrom(name, index, cause) without a 4th argument. Any caller (internal or external) going through a FieldGroup will silently lose avoidTouch, so if this helper is ever invoked from a FieldGroup-scoped removal path it will regress to the old auto-touch behavior. Update FieldGroupApi's signature and forward options for parity.

   validateArrayFieldsStartingFrom = async <
     TField extends DeepKeysOfType<TFieldGroupData, any[]>,
   >(
     field: TField,
     index: number,
     cause: ValidationCause,
+    options?: { avoidTouch?: boolean },
   ) => {
     return this.form.validateArrayFieldsStartingFrom(
       this.getFormFieldName(field),
       index,
       cause,
+      options,
     )
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/form-core/src/FormApi.ts` around lines 1579 - 1586, The
FieldGroupApi.validateArrayFieldsStartingFrom wrapper currently omits the
options parameter so avoidTouch is lost; update FieldGroupApi's method signature
to accept the fourth parameter (options?: { avoidTouch?: boolean }) and forward
it when calling this.form.validateArrayFieldsStartingFrom(name, index, cause,
options) so callers via FieldGroup retain avoidTouch behavior—ensure the method
declaration and the delegated call both include the options parameter and types
match the FormApi.validateArrayFieldsStartingFrom signature.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/form-core/src/FormApi.ts`:
- Around line 1609-1634: The temporary-touch logic inside the Promise.resolve
block when options?.avoidTouch is true should ensure the original isTouched is
always restored even if fieldInstance.validate(cause) throws or rejects: wrap
the validate call and the second setMeta restoration in a try/finally so that
after temporarily setting fieldInstance.setMeta(... isTouched: true) you always
run the restore setMeta(...) in the finally block; keep the early-return of the
validate result, and otherwise fall back to this.validateField(nestedField,
cause). Reference the Promise.resolve(...) block, options?.avoidTouch,
fieldInstance, fieldInstance.validate(cause), and fieldInstance.setMeta(...)
when making the change.

---

Nitpick comments:
In `@packages/form-core/src/FormApi.ts`:
- Around line 1579-1586: The FieldGroupApi.validateArrayFieldsStartingFrom
wrapper currently omits the options parameter so avoidTouch is lost; update
FieldGroupApi's method signature to accept the fourth parameter (options?: {
avoidTouch?: boolean }) and forward it when calling
this.form.validateArrayFieldsStartingFrom(name, index, cause, options) so
callers via FieldGroup retain avoidTouch behavior—ensure the method declaration
and the delegated call both include the options parameter and types match the
FormApi.validateArrayFieldsStartingFrom signature.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e842bc31-0829-4659-8dff-b4c5f0928b2e

📥 Commits

Reviewing files that changed from the base of the PR and between cd491ba and a1294aa.

📒 Files selected for processing (1)
  • packages/form-core/src/FormApi.ts

Comment on lines +1609 to +1634
Promise.resolve().then(async () => {
// If avoidTouch is set and the field instance already exists,
// run validation without permanently marking the field as touched
if (options?.avoidTouch) {
const fieldInstance = this.fieldInfo[nestedField]?.instance
if (fieldInstance) {
const wasTouched = fieldInstance.state.meta.isTouched
if (!wasTouched) {
// Temporarily touch the field so validation runs, then restore
fieldInstance.setMeta((prev) => ({
...prev,
isTouched: true,
}))
}
const result = await fieldInstance.validate(cause)
if (!wasTouched) {
fieldInstance.setMeta((prev) => ({
...prev,
isTouched: false,
}))
}
return result
}
}
return this.validateField(nestedField, cause)
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard restoration with try/finally, and note the transient-state exposure.

Two concerns with the temporary-touch approach:

  1. No error safety. If fieldInstance.validate(cause) throws (sync throw before returning a Promise, or a rejected Promise returned synchronously before the await), the second setMeta never runs and the field is left permanently isTouched: true — reintroducing the exact bug this PR fixes. Wrap in try/finally.
  2. Observable transient state. Between the two setMeta calls, the derived stores (fieldMetaDerived, store.isTouched, devtools broadcast via throttleFormState) momentarily reflect isTouched: true for every untouched sibling. With N shifted siblings all toggling in parallel, subscribers/listeners can observe a spurious "all touched" window. Also, if the user genuinely touches the field during the await, the restoration will clobber their touch back to false.

The try/finally is a strict correctness fix and should be applied. The transient-state issue is the main reason the originally suggested refactor (an internal { touch: false } path on validateField that skips the isTouched check entirely) is cleaner — worth reconsidering as a follow-up.

🛡️ Proposed fix: ensure restoration on error paths
             if (options?.avoidTouch) {
               const fieldInstance = this.fieldInfo[nestedField]?.instance
               if (fieldInstance) {
                 const wasTouched = fieldInstance.state.meta.isTouched
                 if (!wasTouched) {
                   // Temporarily touch the field so validation runs, then restore
                   fieldInstance.setMeta((prev) => ({
                     ...prev,
                     isTouched: true,
                   }))
                 }
-                const result = await fieldInstance.validate(cause)
-                if (!wasTouched) {
-                  fieldInstance.setMeta((prev) => ({
-                    ...prev,
-                    isTouched: false,
-                  }))
-                }
-                return result
+                try {
+                  return await fieldInstance.validate(cause)
+                } finally {
+                  if (!wasTouched) {
+                    fieldInstance.setMeta((prev) => ({
+                      ...prev,
+                      isTouched: false,
+                    }))
+                  }
+                }
               }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/form-core/src/FormApi.ts` around lines 1609 - 1634, The
temporary-touch logic inside the Promise.resolve block when options?.avoidTouch
is true should ensure the original isTouched is always restored even if
fieldInstance.validate(cause) throws or rejects: wrap the validate call and the
second setMeta restoration in a try/finally so that after temporarily setting
fieldInstance.setMeta(... isTouched: true) you always run the restore
setMeta(...) in the finally block; keep the early-return of the validate result,
and otherwise fall back to this.validateField(nestedField, cause). Reference the
Promise.resolve(...) block, options?.avoidTouch, fieldInstance,
fieldInstance.validate(cause), and fieldInstance.setMeta(...) when making the
change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

removeValue on array field incorrectly sets isTouched=true on all shifted siblings

1 participant