fix: useField validated-only mode for arrays (#5029)#5141
fix: useField validated-only mode for arrays (#5029)#5141
Conversation
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>
🦋 Changeset detectedLatest 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 |
✅ Deploy Preview for vee-validate-v5 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for vee-validate-docs canceled.
|
There was a problem hiding this comment.
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 usevalidateValidStateOnly()instead ofvalidateWithStateMutation()invalidated-onlymode when a field hasn't been previously validated (!meta.validated) - Add 9 new integration tests for
useFieldArraywith object items covering schema validation,meta.valid/meta.dirtyupdates, 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.
| 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); | ||
| }); |
There was a problem hiding this comment.
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 mutatefields.value[0].value.name = ''and checkform.meta.value.validbecomesfalseandform.meta.value.dirtybecomestrue— 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 verifyform.meta.value.valid === falsefollowed bysetValue(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 throughfields.value[0].value.nameand verify the form values and meta update.
Consider consolidating the duplicated tests to improve maintainability.
| // 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(); | ||
| } |
There was a problem hiding this comment.
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.
| // The error spans for the newly pushed item should show errors | ||
| const errorSpans = document.querySelectorAll('.error'); | ||
| expect(errorSpans.length).toBe(4); |
There was a problem hiding this comment.
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.
| // 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(); |
Summary
useField.validate()to properly handlevalidated-onlymode by checking the field'svalidatedflag before deciding whether to run full validation with error mutation or silent validationvalidated-onlymode was treated identically toforcemode inuseField, which caused fields that hadn't been interacted with yet to get incorrectly marked as validated and have error messages set whenuseFieldArraytriggered form-wide validationuseFieldArraywith object items, covering schema validation, meta updates (valid/dirty), nested property mutations viacomputedDeep, and interaction withuseFieldDetails
When
useFieldArray.update()callsform.validate({ mode: 'validated-only' })(triggered bycomputedDeepwhenfield.valueproperties change), eachuseFieldinstance'svalidate()function is invoked. Previously, any mode other thansilentwould callvalidateWithStateMutation(), which:meta.validated = trueThis 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-onlymode, if the field hasn't been validated before (!meta.validated), usevalidateValidStateOnly()instead, which only updates thevalidflag without setting errors.Test plan
useFieldArraywith object items triggers schema validation and updatesmeta.validmeta.dirtyupdates correctly when pushing object itemsfield.value.name = '') trigger validationuseFieldcomponents work correctly with nested field array pathsform.validate()shows errors on pushed object fieldsextraErrorsBagwhen nouseFieldis useduseField-bound inputs triggers validation and shows errorsFieldArray.spec.tstestsCloses #5029
🤖 Generated with Claude Code