Skip to content

fix: useField validated-only mode for arrays (#5029)#5141

Open
logaretm wants to merge 1 commit intomainfrom
fix/5029-fieldarray-schema-objects
Open

fix: useField validated-only mode for arrays (#5029)#5141
logaretm wants to merge 1 commit intomainfrom
fix/5029-fieldarray-schema-objects

Conversation

@logaretm
Copy link
Owner

@logaretm logaretm commented Mar 4, 2026

Summary

  • Fix useField.validate() to properly handle validated-only mode by checking the field's validated flag before deciding whether to run full validation with error mutation or silent validation
  • Previously, validated-only mode was treated identically to force mode in useField, which caused fields that hadn't been interacted with yet to get incorrectly marked as validated and have error messages set when useFieldArray triggered form-wide validation
  • Add comprehensive test coverage for useFieldArray with object items, covering schema validation, meta updates (valid/dirty), nested property mutations via computedDeep, and interaction with useField

Details

When useFieldArray.update() calls form.validate({ mode: 'validated-only' }) (triggered by computedDeep when field.value properties change), each useField instance's validate() function is invoked. Previously, any mode other than silent would call validateWithStateMutation(), which:

  1. Sets meta.validated = true
  2. Runs validation
  3. Sets error messages on the field

This was problematic because it would show errors on fields the user hadn't interacted with yet, and incorrectly mark them as validated. The fix adds a check: in validated-only mode, if the field hasn't been validated before (!meta.validated), use validateValidStateOnly() instead, which only updates the valid flag without setting errors.

Test plan

  • Verify useFieldArray with object items triggers schema validation and updates meta.valid
  • Verify meta.dirty updates correctly when pushing object items
  • Verify nested property mutations (field.value.name = '') trigger validation
  • Verify useField components work correctly with nested field array paths
  • Verify explicit form.validate() shows errors on pushed object fields
  • Verify errors propagate to extraErrorsBag when no useField is used
  • Verify typing in useField-bound inputs triggers validation and shows errors
  • Verify no regression in existing FieldArray.spec.ts tests
  • All 367 tests pass (3 pre-existing failures unrelated to this change)

Closes #5029

🤖 Generated with Claude Code

Fix useField.validate() to properly handle 'validated-only' mode by
checking the field's validated flag. Previously, validated-only mode
was treated the same as force mode in useField, causing unintended
side effects when useFieldArray triggered form-wide validation after
mutations. Now, fields that haven't been interacted with yet will only
get their valid flag updated (silent validation) without incorrectly
marking them as validated or setting error messages prematurely.

Also adds comprehensive test coverage for useFieldArray with object
items including schema validation, meta updates (valid/dirty), nested
property mutations via computedDeep, and interaction with useField.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 4, 2026 06:55
@changeset-bot
Copy link

changeset-bot bot commented Mar 4, 2026

🦋 Changeset detected

Latest commit: 625f797

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

@netlify
Copy link

netlify bot commented Mar 4, 2026

Deploy Preview for vee-validate-v5 ready!

Name Link
🔨 Latest commit 625f797
🔍 Latest deploy log https://app.netlify.com/projects/vee-validate-v5/deploys/69a7d75cb0e0960008c58b1a
😎 Deploy Preview https://deploy-preview-5141--vee-validate-v5.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link

netlify bot commented Mar 4, 2026

Deploy Preview for vee-validate-docs canceled.

Name Link
🔨 Latest commit 625f797
🔍 Latest deploy log https://app.netlify.com/projects/vee-validate-docs/deploys/69a7d75c13572e000851db95

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes issue #5029 where useFieldArray with object items failed to honor the validationSchema and update meta.valid/meta.dirty correctly. The core fix prevents useField.validate() from setting errors and marking fields as validated when called in validated-only mode for fields that the user hasn't yet interacted with.

Changes:

  • Fix useField.validate() to use validateValidStateOnly() instead of validateWithStateMutation() in validated-only mode when a field hasn't been previously validated (!meta.validated)
  • Add 9 new integration tests for useFieldArray with object items covering schema validation, meta.valid/meta.dirty updates, nested property mutations, and form-wide validation
  • Add a changeset entry for the patch-level fix

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
packages/vee-validate/src/useField.ts Core fix: guards validated-only validation mode so uninteracted fields don't get errors set
packages/vee-validate/tests/useFieldArray.spec.ts 9 new integration tests for useFieldArray with object items (all use Zod schemas)
.changeset/fix-5029-fieldarray-schema-objects.md Changeset entry for the patch release

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +593 to +1061
test('modifying field.value nested properties should trigger validation and update meta', async () => {
let form!: FormContext;
let fields!: Ref<FieldEntry<{ name: string }>[]>;
mountWithHoc({
setup() {
form = useForm<any>({
initialValues: {
users: [{ name: 'valid' }],
},
validationSchema: z.object({
users: z.array(
z.object({
name: z.string().min(1),
}),
),
}),
});

fields = useFieldArray<{ name: string }>('users').fields;
},
template: `
<div></div>
`,
});

await flushPromises();
expect(form.meta.value.valid).toBe(true);
// Modify a nested property of an object field entry
fields.value[0].value.name = '';
await flushPromises();
expect(form.meta.value.valid).toBe(false);
});

// #5029
test('useField with nested field array object paths should validate correctly', async () => {
let form!: FormContext;
let arr!: FieldArrayContext;

const InputField = defineComponent({
props: {
name: {
type: String,
required: true,
},
},
setup(props) {
const { value, errorMessage } = useField(() => props.name);
return { value, errorMessage };
},
template: '<input v-model="value" /><span class="error">{{ errorMessage }}</span>',
});

mountWithHoc({
components: { InputField },
setup() {
form = useForm<any>({
initialValues: {
users: [{ name: 'valid', email: 'valid@test.com' }],
},
validationSchema: z.object({
users: z.array(
z.object({
name: z.string().min(1),
email: z.string().email(),
}),
),
}),
});

arr = useFieldArray('users');

return {
fields: arr.fields,
};
},
template: `
<div>
<div v-for="(field, idx) in fields" :key="field.key">
<InputField :name="'users[' + idx + '].name'" />
<InputField :name="'users[' + idx + '].email'" />
</div>
</div>
`,
});

await flushPromises();
expect(form.meta.value.valid).toBe(true);
// Push an invalid object item
arr.push({ name: '', email: 'invalid' });
await flushPromises();
// After flushPromises, the new useField components should be rendered
// and the silent validation from afterMutation() should have run
// meta.valid should be false since schema validation fails
expect(form.meta.value.valid).toBe(false);

// The error spans for the newly pushed item should show errors
const errorSpans = document.querySelectorAll('.error');
expect(errorSpans.length).toBe(4);

// Now also verify that when user interacts with a field, errors appear
const inputAt = (idx: number) => (document.querySelectorAll('input') || [])[idx] as HTMLInputElement;
const errorAt = (idx: number) => (document.querySelectorAll('.error') || [])[idx] as HTMLSpanElement;

// Touch the name field of the second item
setValue(inputAt(2), '');
await flushPromises();
// Error should now be shown for the name field
expect(errorAt(2)?.textContent).toBeTruthy();
});

// #5029
test('field array with objects: direct mutation of field.value properties updates errors and meta', async () => {
let form!: FormContext;
let arr!: FieldArrayContext;
let fields!: Ref<FieldEntry<{ name: string; email: string }>[]>;

mountWithHoc({
setup() {
form = useForm<any>({
initialValues: {
users: [{ name: 'valid', email: 'valid@test.com' }],
},
validationSchema: z.object({
users: z.array(
z.object({
name: z.string().min(1),
email: z.string().email(),
}),
),
}),
});

arr = useFieldArray<{ name: string; email: string }>('users');
fields = arr.fields;

return {
fields,
};
},
template: `<div></div>`,
});

await flushPromises();
expect(form.meta.value.valid).toBe(true);

// Direct mutation of a nested property (mimicking v-model="field.value.name")
fields.value[0].value.name = '';
await flushPromises();
// meta.valid should reflect the validation failure
expect(form.meta.value.valid).toBe(false);
// meta.dirty should reflect the change
expect(form.meta.value.dirty).toBe(true);
});

// #5029 - This test verifies the core issue: after pushing an item with useField components,
// errors should be properly propagated to useField path states (not just silent valid flag)
test('field array with objects: push invalid item should show errors on useField path states', async () => {
let form!: FormContext;
let arr!: FieldArrayContext;

const InputField = defineComponent({
props: {
name: {
type: String,
required: true,
},
},
setup(props) {
const { value, errorMessage } = useField(() => props.name);
return { value, errorMessage };
},
template: '<input v-model="value" /><span class="error">{{ errorMessage }}</span>',
});

mountWithHoc({
components: { InputField },
setup() {
form = useForm<any>({
initialValues: {
users: [{ name: 'valid', email: 'valid@test.com' }],
},
validationSchema: z.object({
users: z.array(
z.object({
name: z.string().min(1),
email: z.string().email(),
}),
),
}),
});

arr = useFieldArray('users');

return {
fields: arr.fields,
};
},
template: `
<div>
<div v-for="(field, idx) in fields" :key="field.key">
<InputField :name="'users[' + idx + '].name'" />
<InputField :name="'users[' + idx + '].email'" />
</div>
</div>
`,
});

const inputAt = (idx: number) => (document.querySelectorAll('input') || [])[idx] as HTMLInputElement;
const errorAt = (idx: number) => (document.querySelectorAll('.error') || [])[idx] as HTMLSpanElement;

await flushPromises();
expect(form.meta.value.valid).toBe(true);

// Push an invalid object item
arr.push({ name: '', email: 'not-valid' });
await flushPromises();

// meta.valid should reflect validation failure
expect(form.meta.value.valid).toBe(false);

// At minimum, when the user interacts with the fields, errors should appear
// Currently even after push, interacting with the empty field should show error
setValue(inputAt(2), '');
await flushPromises();
expect(errorAt(2)?.textContent).toBeTruthy();
});

// #5029 - Test the exact pattern from the StackBlitz reproduction
test('field array with objects: v-model on field.value.prop tracks data and validates', async () => {
let form!: FormContext;
let arr!: FieldArrayContext;

mountWithHoc({
setup() {
form = useForm<any>({
initialValues: {
users: [{ name: 'initial', email: 'test@test.com' }],
},
validationSchema: z.object({
users: z.array(
z.object({
name: z.string().min(1),
email: z.string().email(),
}),
),
}),
});

arr = useFieldArray<{ name: string; email: string }>('users');

return {
fields: arr.fields,
errors: form.errors,
meta: form.meta,
};
},
template: `
<div>
<div v-for="(field, idx) in fields" :key="field.key">
<input class="name" :value="field.value.name" @input="field.value = { ...field.value, name: $event.target.value }" />
<input class="email" :value="field.value.email" @input="field.value = { ...field.value, email: $event.target.value }" />
</div>
<span id="valid">{{ meta.valid }}</span>
<span id="dirty">{{ meta.dirty }}</span>
</div>
`,
});

await flushPromises();
const nameInput = document.querySelector('.name') as HTMLInputElement;
const validSpan = document.querySelector('#valid') as HTMLSpanElement;
const dirtySpan = document.querySelector('#dirty') as HTMLSpanElement;

expect(validSpan?.textContent).toBe('true');
expect(dirtySpan?.textContent).toBe('false');

// Simulate typing in the name input (clear it to make it invalid)
setValue(nameInput, '');
await flushPromises();

// After typing, validation should detect the empty name
expect(form.meta.value.valid).toBe(false);
expect(form.meta.value.dirty).toBe(true);
expect(validSpan?.textContent).toBe('false');
expect(dirtySpan?.textContent).toBe('true');
});

// #5029 - This is the real bug scenario: using useField with useFieldArray of objects
// After push, the new fields should show errors when the form is submitted or validated
test('field array with objects: form.validate() shows errors on newly pushed object fields', async () => {
let form!: FormContext;
let arr!: FieldArrayContext;

const InputField = defineComponent({
props: {
name: {
type: String,
required: true,
},
},
setup(props) {
const { value, errorMessage } = useField(() => props.name);
return { value, errorMessage };
},
template: '<input v-model="value" /><span class="error">{{ errorMessage }}</span>',
});

mountWithHoc({
components: { InputField },
setup() {
form = useForm<any>({
initialValues: {
users: [{ name: 'valid', email: 'valid@test.com' }],
},
validationSchema: z.object({
users: z.array(
z.object({
name: z.string().min(1),
email: z.string().email(),
}),
),
}),
});

arr = useFieldArray('users');

return {
fields: arr.fields,
};
},
template: `
<div>
<div v-for="(field, idx) in fields" :key="field.key">
<InputField :name="'users[' + idx + '].name'" />
<InputField :name="'users[' + idx + '].email'" />
</div>
</div>
`,
});

const errorAt = (idx: number) => (document.querySelectorAll('.error') || [])[idx] as HTMLSpanElement;

await flushPromises();
expect(form.meta.value.valid).toBe(true);

// Push an invalid object item
arr.push({ name: '', email: 'not-valid' });
await flushPromises();

// meta.valid should be false
expect(form.meta.value.valid).toBe(false);

// Explicitly validate the form (simulates form submission)
// Need to handle async validation with flush
const validatePromise = form.validate();
await flushPromises();
const result = await validatePromise;
await flushPromises();

// After explicit validation, errors should be shown on the new fields
expect(result.valid).toBe(false);
expect(Object.keys(result.errors).length).toBeGreaterThan(0);

// Error messages should now be displayed in the UI
expect(errorAt(2)?.textContent).toBeTruthy();
expect(errorAt(3)?.textContent).toBeTruthy();
});

// #5029 - computedDeep set callback should propagate nested property mutations to form values
test('field array with objects: computedDeep properly propagates nested property changes to form', async () => {
let form!: FormContext;
let arr!: FieldArrayContext;
let fields!: Ref<FieldEntry<{ name: string; email: string }>[]>;

mountWithHoc({
setup() {
form = useForm<any>({
initialValues: {
users: [{ name: 'initial', email: 'test@test.com' }],
},
validationSchema: z.object({
users: z.array(
z.object({
name: z.string().min(1),
email: z.string().email(),
}),
),
}),
});

arr = useFieldArray<{ name: string; email: string }>('users');
fields = arr.fields;

return {
fields,
};
},
template: `<div></div>`,
});

await flushPromises();

// Verify initial state
expect(form.values.users[0].name).toBe('initial');
expect(form.values.users[0].email).toBe('test@test.com');

// Mutate nested property (like v-model="field.value.name" would do)
fields.value[0].value.name = 'changed';
await flushPromises();

// The form values should reflect the change
expect(form.values.users[0].name).toBe('changed');
// The other property should remain unchanged
expect(form.values.users[0].email).toBe('test@test.com');
});

// #5029 - Using programmatic mutation of field.value properties (simulates v-model on nested property)
test('field array with objects: programmatic mutation of field.value.name triggers validation', async () => {
let form!: FormContext;
let arr!: FieldArrayContext;
let fields!: Ref<FieldEntry<{ name: string; email: string }>[]>;

mountWithHoc({
setup() {
form = useForm<any>({
initialValues: {
users: [{ name: 'initial', email: 'test@test.com' }],
},
validationSchema: z.object({
users: z.array(
z.object({
name: z.string().min(1),
email: z.string().email(),
}),
),
}),
});

arr = useFieldArray<{ name: string; email: string }>('users');
fields = arr.fields;

return {
fields,
};
},
template: `<div></div>`,
});

await flushPromises();
expect(form.meta.value.valid).toBe(true);
expect(form.meta.value.dirty).toBe(false);

// Directly mutate a nested property (simulates v-model="field.value.name")
fields.value[0].value.name = '';
await flushPromises();

// Form values should be updated
expect(form.values.users[0].name).toBe('');
// meta.valid should be false (validation should catch the empty name)
expect(form.meta.value.valid).toBe(false);
// meta.dirty should be true (value changed)
expect(form.meta.value.dirty).toBe(true);

// Fix the value
fields.value[0].value.name = 'fixed';
await flushPromises();
expect(form.values.users[0].name).toBe('fixed');
expect(form.meta.value.valid).toBe(true);
});
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.

There is significant overlap between several of the 9 new test cases. For example:

  • Tests at lines 593–624 ('modifying field.value nested properties should trigger validation and update meta') and 1010–1061 ('field array with objects: programmatic mutation of field.value.name triggers validation') both mutate fields.value[0].value.name = '' and check form.meta.value.valid becomes false and form.meta.value.dirty becomes true — testing exactly the same behavior.

  • Tests at lines 627–701 ('useField with nested field array object paths should validate correctly') and 749–818 ('field array with objects: push invalid item should show errors on useField path states') both push { name: '', email: 'not-valid'/'invalid' } and verify form.meta.value.valid === false followed by setValue(inputAt(2), '') to trigger error display.

  • Tests at lines 704–745 ('field array with objects: direct mutation of field.value properties updates errors and meta') and 961–1007 ('field array with objects: computedDeep properly propagates nested property changes to form') both test direct property mutation through fields.value[0].value.name and verify the form values and meta update.

Consider consolidating the duplicated tests to improve maintainability.

Copilot uses AI. Check for mistakes.
Comment on lines +210 to +214
// In validated-only mode, only validate fields that have been previously validated
// This prevents showing errors on fields the user hasn't interacted with yet (#5029)
if (opts?.mode === 'validated-only' && !meta.validated) {
return validateValidStateOnly();
}
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 fix in useField.ts (lines 212-214) guards validate() in validated-only mode when !meta.validated, and this path is only reached when form.validate({ mode: 'validated-only' }) is called without a schema — i.e., when individual field validators are used. However, all 9 new tests use Zod validationSchema, so when form.validate() is called it goes through form.validateSchema() (see useForm.ts line 831-832) and the fixed code in useField.validate() is never reached.

A test that would actually cover the fixed behavior would need to use useField with per-field validation rules (instead of a form-level schema) alongside useFieldArray, mutate a field entry's value (triggering update()form.validate({ mode: 'validated-only' })), and verify that fields with meta.validated === false do NOT get their errors set, while already-validated fields still do.

Without such a test, a regression in the fix would go undetected.

Copilot uses AI. Check for mistakes.
Comment on lines +688 to +690
// The error spans for the newly pushed item should show errors
const errorSpans = document.querySelectorAll('.error');
expect(errorSpans.length).toBe(4);
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 comment on line 688 states "The error spans for the newly pushed item should show errors", but the assertion expect(errorSpans.length).toBe(4) only verifies the count of span elements rendered (2 original + 2 new = 4), not whether the spans actually contain error text. This is misleading: the comment implies the newly pushed invalid item's fields show error messages, while the assertion doesn't check for that at all. The comment should either be updated to reflect what is actually being asserted (span count), or the assertion should check that the error spans for the new item are non-empty.

Suggested change
// The error spans for the newly pushed item should show errors
const errorSpans = document.querySelectorAll('.error');
expect(errorSpans.length).toBe(4);
// The error spans for the newly pushed item should show errors, and the total count should reflect both items
const errorSpans = document.querySelectorAll('.error');
expect(errorSpans.length).toBe(4);
// The first item's fields are valid, so their error spans should be empty,
// while the newly pushed invalid item's error spans should contain messages
expect(errorSpans[2]?.textContent).toBeTruthy();
expect(errorSpans[3]?.textContent).toBeTruthy();

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

With useFieldArray the validationSchema is not honoured and the meta binding is not updated if using objects

2 participants