fix: radio buttons with same name handle errors correctly (#5001)#5147
fix: radio buttons with same name handle errors correctly (#5001)#5147
Conversation
When multiple Field components share the same name (e.g. radio buttons), only the last one would receive validation errors. This happened because createPathState only reused existing path states for fields explicitly typed as checkbox/radio, causing separate path states to be created when type was not specified on the Field component. The fix makes createPathState reuse existing path states for ALL fields sharing the same path, not just checkbox/radio types. This ensures: - All fields with the same name share a single PathState and its errors - removePathState properly decrements fieldsCount for all shared fields - Unmount logic handles array IDs for non-multiple shared fields - Path values are only unset when the last field unmounts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: ab611b8 The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
✅ Deploy Preview for vee-validate-v5 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for vee-validate-docs canceled.
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a regression where multiple <Field> components sharing the same name (notably radio button groups) would not all receive/reflect validation errors, by making useForm reuse a single shared PathState per normalized path and adjusting unmount/removal behavior accordingly.
Changes:
- Broaden
createPathStatereuse to any field sharing the same normalized path; adjustremovePathStateto decrementfieldsCountfor all shared states and remove only whenfieldsCount <= 0. - Update
useFieldunmount cleanup to match array ids regardless ofmultiple, and to avoid unsetting shared path values except when appropriate. - Add regression tests for #5001 and update the #4643 test expectations to reflect the new shared
PathStatestructure; add a changeset entry.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/vee-validate/src/useForm.ts | Reuses PathState for all fields sharing a normalized path; updates shared-state teardown semantics. |
| packages/vee-validate/src/useField.ts | Adjusts unmount id matching for shared states and changes when path values are unset during cleanup. |
| packages/vee-validate/tests/Form.spec.ts | Updates #4643 test to shared state shape; adds two new regression tests for shared radio errors (#5001). |
| .changeset/fix-5001-radio-same-name.md | Declares a patch release note for the #5001 fix. |
Comments suppressed due to low confidence (1)
packages/vee-validate/src/useForm.ts:283
createPathStatenow reuses an existingPathStatefor any field that shares the same path, but the reuse branch doesn't reconcile per-field config likevalidate/bails/type/label. BecauseuseForm.validate()and form submissions rely onpathState.validate, this can cause the form to validate using the first mounted field's validator even when a later field with the same name has different rules (e.g. the #4643 scenario:useField('foo')registers an always-valid validator, then a<Field name="foo" rules="required" />reuses the state but doesn't update the validator, so submit/validate()can incorrectly pass). Consider tracking validators per field id and selecting/combining the active one, or otherwise updatingpathState.validateon reuse and restoring it appropriately on unmount. Adding a regression test that submits/validates in the #4643 scenario would help prevent this.
const pathValue = toValue(path);
const pathStateExists = pathStateLookup.value[pathValue];
const isCheckboxOrRadio = config?.type === 'checkbox' || config?.type === 'radio';
if (pathStateExists && normalizeFormPath(toValue(pathStateExists.path)) === normalizeFormPath(pathValue)) {
if (isCheckboxOrRadio) {
pathStateExists.multiple = true;
}
const id = FIELD_ID_COUNTER++;
if (Array.isArray(pathStateExists.id)) {
pathStateExists.id.push(id);
} else {
pathStateExists.id = [pathStateExists.id, id];
}
pathStateExists.fieldsCount++;
pathStateExists.__flags.pendingUnmount[id] = false;
return pathStateExists as PathState<TValues[TPath], TOutput[TPath]>;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } else if (pathState?.multiple || pathState?.fieldsCount <= 1) { | ||
| form.unsetPathValue(toValue(name)); |
There was a problem hiding this comment.
The unmount cleanup unsets the path value whenever pathState.multiple is true, even when unmounting a radio option that isn't currently selected. For radio groups multiple is set to true but pathState.value is a scalar, so removing any option will clear the whole model value (and may unintentionally uncheck the remaining radios). Consider only unsetting for radios when the current value matches the unmounted option’s checkedValue (or when it’s the last remaining field), rather than unconditionally for any multiple pathState.
| } else if (pathState?.multiple || pathState?.fieldsCount <= 1) { | |
| form.unsetPathValue(toValue(name)); | |
| } else if (pathState) { | |
| const isMultiple = !!pathState.multiple; | |
| const hasFieldsCount = typeof pathState.fieldsCount === 'number'; | |
| const isLastField = hasFieldsCount && pathState.fieldsCount <= 1; | |
| const isRadioLike = isMultiple && !Array.isArray(pathState.value); | |
| const shouldUnsetForRadio = | |
| isRadioLike && (isLastField || isEqual(pathState.value, toValue(field.checkedValue))); | |
| const shouldUnsetForSingle = !isMultiple && isLastField; | |
| if (shouldUnsetForRadio || shouldUnsetForSingle) { | |
| form.unsetPathValue(path); | |
| } |
| const REQUIRED_MSG = 'This field is required'; | ||
| defineRule('required', (value: unknown) => { | ||
| if (!value) { | ||
| return REQUIRED_MSG; | ||
| } | ||
| return true; | ||
| }); |
There was a problem hiding this comment.
This file already defines the required rule and REQUIRED_MESSAGE at the top of the suite. Re-defining required inside these new tests is redundant and mutates global rule state, which can make future tests added after these ones order-dependent. Prefer reusing the existing REQUIRED_MESSAGE and rule registration instead of calling defineRule('required', ...) again here.
Summary
<Field>components share the samename(e.g. radio buttons), only the last one would receive validation errorscreatePathStateonly reused existing path states for fields explicitly typed ascheckboxorradio. Whentypewas not specified on the<Field>component (common when wrapping radio inputs in a custom component), separate path states were created, and only the last one in the lookup would receive errors fromsetFieldErrorcreatePathStatereuse existing path states for ALL fields sharing the same path, not just checkbox/radio types. It also updatesremovePathStateand unmount logic to properly handle shared non-multiple fieldsChanges
packages/vee-validate/src/useForm.ts: Broadened thecreatePathStatereuse condition to match any field with the same path (with a normalized path identity check to avoid stale field array paths). UpdatedremovePathStateto decrementfieldsCountfor all shared fields and only remove the path state whenfieldsCount <= 0packages/vee-validate/src/useField.ts: Updated the unmount ID matching logic to handle array IDs regardless ofmultipleflag. Only unsets path values when it is the last field or a radio/checkbox grouppackages/vee-validate/tests/Form.spec.ts: Added two regression tests for Radiobutton same name errors problem #5001 and updated the Fields don't clean up their rules properly #4643 test to reflect the new shared pathState behaviorTest plan
type="radio"on Field should all show errors when they share the same nametype="radio"and same name should all show errors via v-slot🤖 Generated with Claude Code