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-4981-empty-array-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"vee-validate": patch
---

Fix error message not showing when field array is empty (#4981)
4 changes: 3 additions & 1 deletion packages/vee-validate/src/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,9 @@ export function useForm<
const results = paths.reduce(
(validation, _path) => {
const expectedPath = _path as Path<TValues>;
const pathState = findPathState(expectedPath) || findHoistedPath(expectedPath);
const isFieldArrayPath = fieldArrays.some(a => toValue(a.path) === expectedPath);
const pathState =
findPathState(expectedPath) || (isFieldArrayPath ? undefined : findHoistedPath(expectedPath));
Comment on lines +369 to +371
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.

isFieldArrayPath compares toValue(a.path) and expectedPath as raw strings. Standard-schema issues are converted using getDotPath, which yields dot-index paths (e.g. users.0.friends), while vee-validate commonly normalizes array indices to bracket syntax (e.g. users[0].friends). If a field array path includes numeric indices, this equality check will fail and hoisting will still occur, leaving the original bug unfixed for those cases. Consider normalizing both sides (and ideally precomputing a normalized Set of field array paths once per validation run) before doing the comparison.

Copilot uses AI. Check for mistakes.
const messages = formResult.results[expectedPath]?.errors || [];
// This is the real path of the field, because it might've been a hoisted field
const path = (toValue(pathState?.path) || expectedPath) as Path<TValues>;
Expand Down
66 changes: 66 additions & 0 deletions packages/vee-validate/tests/useFieldArray.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,72 @@ test('array move initializes the array if undefined', async () => {
expect(arr.fields.value).toHaveLength(0);
});

// #4981
test('shows validation error when field array is empty after removing all items', async () => {
let form!: FormContext;
let arr!: FieldArrayContext;
mountWithHoc({
setup() {
form = useForm<any>({
initialValues: {
users: ['one'],
},
validationSchema: z.object({
users: z.array(z.string()).min(1, 'At least one item is required'),
}),
});

arr = useFieldArray('users');
},
template: `
<div></div>
`,
});
Comment on lines +528 to +544
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.

These new tests mount an empty template and only call useForm + useFieldArray, which (per useFieldArray.ts) does not register any PathStates. Without a parent PathState candidate, findHoistedPath() would return undefined even on the pre-fix code path, so the tests may pass without actually covering the hoisting bug described in #4981. Consider rendering/registering a parent field (e.g. a <Field name="settings" /> / useField('settings'), or reproducing the setFieldValue('settings', ...) pattern from the issue) so the tests fail on the buggy behavior and validate the fix.

Copilot uses AI. Check for mistakes.

await flushPromises();
expect(form.meta.value.valid).toBe(true);
arr.remove(0);
await flushPromises();
expect(form.meta.value.valid).toBe(false);
expect(form.errors.value['users']).toBe('At least one item is required');
});

// #4981
test('shows validation error at correct path when nested field array is empty', async () => {
let form!: FormContext;
let arr!: FieldArrayContext;
mountWithHoc({
setup() {
form = useForm<any>({
initialValues: {
settings: {
items: ['one'],
},
},
validationSchema: z.object({
settings: z.object({
items: z.array(z.string()).min(1, 'At least one item is required'),
}),
}),
});

arr = useFieldArray('settings.items');
},
template: `
<div></div>
`,
});

await flushPromises();
expect(form.meta.value.valid).toBe(true);
arr.remove(0);
await flushPromises();
expect(form.meta.value.valid).toBe(false);
// The error should be at 'settings.items', NOT at 'settings'
expect(form.errors.value['settings.items']).toBe('At least one item is required');
expect(form.errors.value['settings']).toBeUndefined();
});

// #4557
test('errors are available to the newly inserted items', async () => {
let arr!: FieldArrayContext;
Expand Down
Loading