From ab611b8d297f2b2f8adf916b023fe402e9913733 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 4 Mar 2026 02:07:57 -0500 Subject: [PATCH] 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 --- .changeset/fix-5001-radio-same-name.md | 5 ++ packages/vee-validate/src/useField.ts | 7 +- packages/vee-validate/src/useForm.ts | 15 ++-- packages/vee-validate/tests/Form.spec.ts | 110 +++++++++++++++++++++-- 4 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-5001-radio-same-name.md diff --git a/.changeset/fix-5001-radio-same-name.md b/.changeset/fix-5001-radio-same-name.md new file mode 100644 index 000000000..676a2f54c --- /dev/null +++ b/.changeset/fix-5001-radio-same-name.md @@ -0,0 +1,5 @@ +--- +"vee-validate": patch +--- + +Fix radio button same name validation errors (#5001) diff --git a/packages/vee-validate/src/useField.ts b/packages/vee-validate/src/useField.ts index 423683d1b..e85159a6f 100644 --- a/packages/vee-validate/src/useField.ts +++ b/packages/vee-validate/src/useField.ts @@ -386,10 +386,7 @@ function _useField( 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; } @@ -405,7 +402,7 @@ function _useField( if (Array.isArray(pathState.id)) { pathState.id.splice(pathState.id.indexOf(field.id), 1); } - } else { + } else if (pathState?.multiple || pathState?.fieldsCount <= 1) { form.unsetPathValue(toValue(name)); } diff --git a/packages/vee-validate/src/useForm.ts b/packages/vee-validate/src/useForm.ts index f5e7329b8..5f6ca0fce 100644 --- a/packages/vee-validate/src/useForm.ts +++ b/packages/vee-validate/src/useForm.ts @@ -262,10 +262,14 @@ export function useForm< config?: Partial>, ): PathState { 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); @@ -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) { @@ -576,7 +579,7 @@ export function useForm< validateField(path, { mode: 'silent', warn: false }); }); - if (pathState.multiple && pathState.fieldsCount) { + if (pathState.fieldsCount) { pathState.fieldsCount--; } @@ -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(); diff --git a/packages/vee-validate/tests/Form.spec.ts b/packages/vee-validate/tests/Form.spec.ts index 7e0e43cda..8de7db0a2 100644 --- a/packages/vee-validate/tests/Form.spec.ts +++ b/packages/vee-validate/tests/Form.spec.ts @@ -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 () => { @@ -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; + }); + + const wrapper = mountWithHoc({ + template: ` + + + + {{ errors[0] }} + + + + {{ errors[0] }} + + + + {{ errors[0] }} + + + {{ formErrors.drink }} + + + `, + }); + + 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: ` + + + + {{ errors[0] }} + + + + {{ errors[0] }} + + + + {{ errors[0] }} + + + + + `, + }); + + 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); +});