Skip to content

Commit ab611b8

Browse files
logaretmclaude
andcommitted
fix: radio buttons with same name handle errors correctly (#5001)
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>
1 parent 7d8cc52 commit ab611b8

File tree

4 files changed

+121
-16
lines changed

4 files changed

+121
-16
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"vee-validate": patch
3+
---
4+
5+
Fix radio button same name validation errors (#5001)

packages/vee-validate/src/useField.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -386,10 +386,7 @@ function _useField<TValue = unknown>(
386386

387387
flags.pendingUnmount[field.id] = true;
388388
const pathState = form.getPathState(path);
389-
const matchesId =
390-
Array.isArray(pathState?.id) && pathState?.multiple
391-
? pathState?.id.includes(field.id)
392-
: pathState?.id === field.id;
389+
const matchesId = Array.isArray(pathState?.id) ? pathState?.id.includes(field.id) : pathState?.id === field.id;
393390
if (!matchesId) {
394391
return;
395392
}
@@ -405,7 +402,7 @@ function _useField<TValue = unknown>(
405402
if (Array.isArray(pathState.id)) {
406403
pathState.id.splice(pathState.id.indexOf(field.id), 1);
407404
}
408-
} else {
405+
} else if (pathState?.multiple || pathState?.fieldsCount <= 1) {
409406
form.unsetPathValue(toValue(name));
410407
}
411408

packages/vee-validate/src/useForm.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,14 @@ export function useForm<
262262
config?: Partial<PathStateConfig<TOutput[TPath]>>,
263263
): PathState<TValues[TPath], TOutput[TPath]> {
264264
const initialValue = computed(() => getFromPath(initialValues.value, toValue(path)));
265-
const pathStateExists = pathStateLookup.value[toValue(path)];
265+
const pathValue = toValue(path);
266+
const pathStateExists = pathStateLookup.value[pathValue];
266267
const isCheckboxOrRadio = config?.type === 'checkbox' || config?.type === 'radio';
267-
if (pathStateExists && isCheckboxOrRadio) {
268-
pathStateExists.multiple = true;
268+
if (pathStateExists && normalizeFormPath(toValue(pathStateExists.path)) === normalizeFormPath(pathValue)) {
269+
if (isCheckboxOrRadio) {
270+
pathStateExists.multiple = true;
271+
}
272+
269273
const id = FIELD_ID_COUNTER++;
270274
if (Array.isArray(pathStateExists.id)) {
271275
pathStateExists.id.push(id);
@@ -280,7 +284,6 @@ export function useForm<
280284
}
281285

282286
const currentValue = computed(() => getFromPath(formValues, toValue(path)));
283-
const pathValue = toValue(path);
284287

285288
const unsetBatchIndex = UNSET_BATCH.findIndex(_path => _path === pathValue);
286289
if (unsetBatchIndex !== -1) {
@@ -576,7 +579,7 @@ export function useForm<
576579
validateField(path, { mode: 'silent', warn: false });
577580
});
578581

579-
if (pathState.multiple && pathState.fieldsCount) {
582+
if (pathState.fieldsCount) {
580583
pathState.fieldsCount--;
581584
}
582585

@@ -589,7 +592,7 @@ export function useForm<
589592
delete pathState.__flags.pendingUnmount[id];
590593
}
591594

592-
if (!pathState.multiple || pathState.fieldsCount <= 0) {
595+
if (pathState.fieldsCount <= 0) {
593596
pathStates.value.splice(idx, 1);
594597
unsetInitialValue(path);
595598
rebuildPathLookup();

packages/vee-validate/tests/Form.spec.ts

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3182,16 +3182,14 @@ test('removes proper pathState when field is unmounting', async () => {
31823182
await flushPromises();
31833183

31843184
expect(form.meta.value.valid).toBe(false);
3185-
expect(form.getAllPathStates()).toMatchObject([
3186-
{ id: 0, path: 'foo' },
3187-
{ id: 1, path: 'foo' },
3188-
]);
3185+
// Both fields share the same pathState with an array of ids
3186+
expect(form.getAllPathStates()).toMatchObject([{ id: [0, 1], path: 'foo', fieldsCount: 2 }]);
31893187

31903188
renderTemplateField.value = false;
31913189
await flushPromises();
31923190

31933191
expect(form.meta.value.valid).toBe(true);
3194-
expect(form.getAllPathStates()).toMatchObject([{ id: 0, path: 'foo' }]);
3192+
expect(form.getAllPathStates()).toMatchObject([{ id: [0], path: 'foo', fieldsCount: 1 }]);
31953193
});
31963194

31973195
test('handles onSubmit with generic object from zod schema', async () => {
@@ -3241,3 +3239,105 @@ test('handles onSubmit with generic object from zod schema', async () => {
32413239
expect.anything(),
32423240
);
32433241
});
3242+
3243+
// #5001 - radio buttons without explicit type on Field should share errors
3244+
test('radio buttons with same name should all expose errors even without type=radio on Field', async () => {
3245+
const REQUIRED_MSG = 'This field is required';
3246+
defineRule('required', (value: unknown) => {
3247+
if (!value) {
3248+
return REQUIRED_MSG;
3249+
}
3250+
return true;
3251+
});
3252+
3253+
const wrapper = mountWithHoc({
3254+
template: `
3255+
<VForm v-slot="{ errors: formErrors }">
3256+
<Field name="drink" rules="required" v-slot="{ errors, handleChange }">
3257+
<input type="radio" name="drink" value="" @change="handleChange" />
3258+
<span class="err-coffee">{{ errors[0] }}</span>
3259+
</Field>
3260+
<Field name="drink" rules="required" v-slot="{ errors, handleChange }">
3261+
<input type="radio" name="drink" value="Tea" @change="handleChange($event.target.value)" />
3262+
<span class="err-tea">{{ errors[0] }}</span>
3263+
</Field>
3264+
<Field name="drink" rules="required" v-slot="{ errors, handleChange }">
3265+
<input type="radio" name="drink" value="Coke" @change="handleChange($event.target.value)" />
3266+
<span class="err-coke">{{ errors[0] }}</span>
3267+
</Field>
3268+
3269+
<span id="form-err">{{ formErrors.drink }}</span>
3270+
<button>Submit</button>
3271+
</VForm>
3272+
`,
3273+
});
3274+
3275+
const errCoffee = wrapper.$el.querySelector('.err-coffee');
3276+
const errTea = wrapper.$el.querySelector('.err-tea');
3277+
const errCoke = wrapper.$el.querySelector('.err-coke');
3278+
const formErr = wrapper.$el.querySelector('#form-err');
3279+
3280+
// Submit without selecting a radio button to trigger required error
3281+
wrapper.$el.querySelector('button').click();
3282+
await flushPromises();
3283+
3284+
// All radio buttons should show the same error, not just the last one
3285+
expect(formErr.textContent).toBe(REQUIRED_MSG);
3286+
expect(errCoffee.textContent).toBe(REQUIRED_MSG);
3287+
expect(errTea.textContent).toBe(REQUIRED_MSG);
3288+
expect(errCoke.textContent).toBe(REQUIRED_MSG);
3289+
});
3290+
3291+
// #5001 - radio buttons with explicit type=radio on Field should share errors
3292+
test('radio buttons with type=radio and same name should all expose errors', async () => {
3293+
const REQUIRED_MSG = 'This field is required';
3294+
defineRule('required', (value: unknown) => {
3295+
if (!value) {
3296+
return REQUIRED_MSG;
3297+
}
3298+
return true;
3299+
});
3300+
3301+
const wrapper = mountWithHoc({
3302+
setup() {
3303+
const schema = {
3304+
drink: 'required',
3305+
};
3306+
3307+
return {
3308+
schema,
3309+
};
3310+
},
3311+
template: `
3312+
<VForm :validation-schema="schema">
3313+
<Field name="drink" type="radio" value="" v-slot="{ errors, field }">
3314+
<input type="radio" v-bind="field" value="" />
3315+
<span class="err-coffee">{{ errors[0] }}</span>
3316+
</Field>
3317+
<Field name="drink" type="radio" value="Tea" v-slot="{ errors, field }">
3318+
<input type="radio" v-bind="field" value="Tea" />
3319+
<span class="err-tea">{{ errors[0] }}</span>
3320+
</Field>
3321+
<Field name="drink" type="radio" value="Coke" v-slot="{ errors, field }">
3322+
<input type="radio" v-bind="field" value="Coke" />
3323+
<span class="err-coke">{{ errors[0] }}</span>
3324+
</Field>
3325+
3326+
<button>Submit</button>
3327+
</VForm>
3328+
`,
3329+
});
3330+
3331+
const errCoffee = wrapper.$el.querySelector('.err-coffee');
3332+
const errTea = wrapper.$el.querySelector('.err-tea');
3333+
const errCoke = wrapper.$el.querySelector('.err-coke');
3334+
3335+
// Submit without selecting a radio button to trigger required error
3336+
wrapper.$el.querySelector('button').click();
3337+
await flushPromises();
3338+
3339+
// All radio buttons should show the same error
3340+
expect(errCoffee.textContent).toBe(REQUIRED_MSG);
3341+
expect(errTea.textContent).toBe(REQUIRED_MSG);
3342+
expect(errCoke.textContent).toBe(REQUIRED_MSG);
3343+
});

0 commit comments

Comments
 (0)