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
5 changes: 5 additions & 0 deletions .changeset/fix-5001-radio-same-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"vee-validate": patch
---

Fix radio button same name validation errors (#5001)
7 changes: 2 additions & 5 deletions packages/vee-validate/src/useField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,7 @@

flags.pendingUnmount[field.id] = true;
const pathState = form.getPathState(path);
const matchesId =
Array.isArray(pathState?.id) && pathState?.multiple
? pathState?.id.includes(field.id)
: pathState?.id === field.id;
const matchesId = Array.isArray(pathState?.id) ? pathState?.id.includes(field.id) : pathState?.id === field.id;
if (!matchesId) {
return;
}
Expand All @@ -405,7 +402,7 @@
if (Array.isArray(pathState.id)) {
pathState.id.splice(pathState.id.indexOf(field.id), 1);
}
} else {
} else if (pathState?.multiple || pathState?.fieldsCount <= 1) {

Check failure on line 405 in packages/vee-validate/src/useField.ts

View workflow job for this annotation

GitHub Actions / typecheck

'pathState.fieldsCount' is possibly 'undefined'.
form.unsetPathValue(toValue(name));
Comment on lines +405 to 406
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
} 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);
}

Copilot uses AI. Check for mistakes.
}

Expand Down
15 changes: 9 additions & 6 deletions packages/vee-validate/src/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,14 @@ export function useForm<
config?: Partial<PathStateConfig<TOutput[TPath]>>,
): PathState<TValues[TPath], TOutput[TPath]> {
const initialValue = computed(() => getFromPath(initialValues.value, toValue(path)));
const pathStateExists = pathStateLookup.value[toValue(path)];
const pathValue = toValue(path);
const pathStateExists = pathStateLookup.value[pathValue];
const isCheckboxOrRadio = config?.type === 'checkbox' || config?.type === 'radio';
if (pathStateExists && isCheckboxOrRadio) {
pathStateExists.multiple = true;
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);
Expand All @@ -280,7 +284,6 @@ export function useForm<
}

const currentValue = computed(() => getFromPath(formValues, toValue(path)));
const pathValue = toValue(path);

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

if (pathState.multiple && pathState.fieldsCount) {
if (pathState.fieldsCount) {
pathState.fieldsCount--;
}

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

if (!pathState.multiple || pathState.fieldsCount <= 0) {
if (pathState.fieldsCount <= 0) {
pathStates.value.splice(idx, 1);
unsetInitialValue(path);
rebuildPathLookup();
Expand Down
110 changes: 105 additions & 5 deletions packages/vee-validate/tests/Form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3182,16 +3182,14 @@ test('removes proper pathState when field is unmounting', async () => {
await flushPromises();

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

renderTemplateField.value = false;
await flushPromises();

expect(form.meta.value.valid).toBe(true);
expect(form.getAllPathStates()).toMatchObject([{ id: 0, path: 'foo' }]);
expect(form.getAllPathStates()).toMatchObject([{ id: [0], path: 'foo', fieldsCount: 1 }]);
});

test('handles onSubmit with generic object from zod schema', async () => {
Expand Down Expand Up @@ -3241,3 +3239,105 @@ test('handles onSubmit with generic object from zod schema', async () => {
expect.anything(),
);
});

// #5001 - radio buttons without explicit type on Field should share errors
test('radio buttons with same name should all expose errors even without type=radio on Field', async () => {
const REQUIRED_MSG = 'This field is required';
defineRule('required', (value: unknown) => {
if (!value) {
return REQUIRED_MSG;
}
return true;
});
Comment on lines +3245 to +3251
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

const wrapper = mountWithHoc({
template: `
<VForm v-slot="{ errors: formErrors }">
<Field name="drink" rules="required" v-slot="{ errors, handleChange }">
<input type="radio" name="drink" value="" @change="handleChange" />
<span class="err-coffee">{{ errors[0] }}</span>
</Field>
<Field name="drink" rules="required" v-slot="{ errors, handleChange }">
<input type="radio" name="drink" value="Tea" @change="handleChange($event.target.value)" />
<span class="err-tea">{{ errors[0] }}</span>
</Field>
<Field name="drink" rules="required" v-slot="{ errors, handleChange }">
<input type="radio" name="drink" value="Coke" @change="handleChange($event.target.value)" />
<span class="err-coke">{{ errors[0] }}</span>
</Field>

<span id="form-err">{{ formErrors.drink }}</span>
<button>Submit</button>
</VForm>
`,
});

const errCoffee = wrapper.$el.querySelector('.err-coffee');
const errTea = wrapper.$el.querySelector('.err-tea');
const errCoke = wrapper.$el.querySelector('.err-coke');
const formErr = wrapper.$el.querySelector('#form-err');

// Submit without selecting a radio button to trigger required error
wrapper.$el.querySelector('button').click();
await flushPromises();

// All radio buttons should show the same error, not just the last one
expect(formErr.textContent).toBe(REQUIRED_MSG);
expect(errCoffee.textContent).toBe(REQUIRED_MSG);
expect(errTea.textContent).toBe(REQUIRED_MSG);
expect(errCoke.textContent).toBe(REQUIRED_MSG);
});

// #5001 - radio buttons with explicit type=radio on Field should share errors
test('radio buttons with type=radio and same name should all expose errors', async () => {
const REQUIRED_MSG = 'This field is required';
defineRule('required', (value: unknown) => {
if (!value) {
return REQUIRED_MSG;
}
return true;
});

const wrapper = mountWithHoc({
setup() {
const schema = {
drink: 'required',
};

return {
schema,
};
},
template: `
<VForm :validation-schema="schema">
<Field name="drink" type="radio" value="" v-slot="{ errors, field }">
<input type="radio" v-bind="field" value="" />
<span class="err-coffee">{{ errors[0] }}</span>
</Field>
<Field name="drink" type="radio" value="Tea" v-slot="{ errors, field }">
<input type="radio" v-bind="field" value="Tea" />
<span class="err-tea">{{ errors[0] }}</span>
</Field>
<Field name="drink" type="radio" value="Coke" v-slot="{ errors, field }">
<input type="radio" v-bind="field" value="Coke" />
<span class="err-coke">{{ errors[0] }}</span>
</Field>

<button>Submit</button>
</VForm>
`,
});

const errCoffee = wrapper.$el.querySelector('.err-coffee');
const errTea = wrapper.$el.querySelector('.err-tea');
const errCoke = wrapper.$el.querySelector('.err-coke');

// Submit without selecting a radio button to trigger required error
wrapper.$el.querySelector('button').click();
await flushPromises();

// All radio buttons should show the same error
expect(errCoffee.textContent).toBe(REQUIRED_MSG);
expect(errTea.textContent).toBe(REQUIRED_MSG);
expect(errCoke.textContent).toBe(REQUIRED_MSG);
});
Loading