Skip to content

Commit 7918fe8

Browse files
logaretmclaude
andcommitted
fix: restore useField meta object reactivity sync (#5021)
Since v4.12.0, calling useField with the same path in multiple components (e.g. parent and child) within the same form would create separate PathState objects instead of sharing one. This broke meta object synchronization — touching a field in the child would not update the parent's meta. The regression was introduced when createPathState was changed to only reuse existing path states for checkbox/radio fields. This restores the v4.11.8 behavior where all field types sharing a path reuse the same PathState, while also: - Adding a path identity check to avoid reusing stale path states in field array scenarios where paths shift (e.g. after insert) - Updating removePathState to properly decrement fieldsCount for all shared fields (not just checkbox/radio) - Fixing the unmount logic to handle array IDs for non-multiple shared fields - Only unsetting path values on unmount when it is the last field or a radio/checkbox group Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7d8cc52 commit 7918fe8

5 files changed

Lines changed: 73 additions & 16 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"vee-validate": patch
3+
---
4+
5+
Fix useField meta object syncing regression from v4.11.8 (#5021)

packages/vee-validate/src/useField.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -386,10 +386,7 @@ function _useField<TValue = unknown>(
386386

387387
flags.pendingUnmount[field.id] = true;
388388
const pathState = form.getPathState(path);
389-
const matchesId =
390-
Array.isArray(pathState?.id) && pathState?.multiple
391-
? pathState?.id.includes(field.id)
392-
: pathState?.id === field.id;
389+
const matchesId = Array.isArray(pathState?.id) ? pathState?.id.includes(field.id) : pathState?.id === field.id;
393390
if (!matchesId) {
394391
return;
395392
}
@@ -405,7 +402,7 @@ function _useField<TValue = unknown>(
405402
if (Array.isArray(pathState.id)) {
406403
pathState.id.splice(pathState.id.indexOf(field.id), 1);
407404
}
408-
} else {
405+
} else if (pathState?.multiple || pathState?.fieldsCount <= 1) {
409406
form.unsetPathValue(toValue(name));
410407
}
411408

packages/vee-validate/src/useForm.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,14 @@ export function useForm<
262262
config?: Partial<PathStateConfig<TOutput[TPath]>>,
263263
): PathState<TValues[TPath], TOutput[TPath]> {
264264
const initialValue = computed(() => getFromPath(initialValues.value, toValue(path)));
265-
const pathStateExists = pathStateLookup.value[toValue(path)];
265+
const pathValue = toValue(path);
266+
const pathStateExists = pathStateLookup.value[pathValue];
266267
const isCheckboxOrRadio = config?.type === 'checkbox' || config?.type === 'radio';
267-
if (pathStateExists && isCheckboxOrRadio) {
268-
pathStateExists.multiple = true;
268+
if (pathStateExists && normalizeFormPath(toValue(pathStateExists.path)) === normalizeFormPath(pathValue)) {
269+
if (isCheckboxOrRadio) {
270+
pathStateExists.multiple = true;
271+
}
272+
269273
const id = FIELD_ID_COUNTER++;
270274
if (Array.isArray(pathStateExists.id)) {
271275
pathStateExists.id.push(id);
@@ -280,7 +284,6 @@ export function useForm<
280284
}
281285

282286
const currentValue = computed(() => getFromPath(formValues, toValue(path)));
283-
const pathValue = toValue(path);
284287

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

579-
if (pathState.multiple && pathState.fieldsCount) {
582+
if (pathState.fieldsCount) {
580583
pathState.fieldsCount--;
581584
}
582585

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

592-
if (!pathState.multiple || pathState.fieldsCount <= 0) {
595+
if (pathState.fieldsCount <= 0) {
593596
pathStates.value.splice(idx, 1);
594597
unsetInitialValue(path);
595598
rebuildPathLookup();

packages/vee-validate/tests/Form.spec.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3181,17 +3181,15 @@ test('removes proper pathState when field is unmounting', async () => {
31813181
renderTemplateField.value = true;
31823182
await flushPromises();
31833183

3184+
// Both useField calls share the same path state since they use the same path
31843185
expect(form.meta.value.valid).toBe(false);
3185-
expect(form.getAllPathStates()).toMatchObject([
3186-
{ id: 0, path: 'foo' },
3187-
{ id: 1, path: 'foo' },
3188-
]);
3186+
expect(form.getAllPathStates()).toMatchObject([{ id: [0, 1], path: 'foo' }]);
31893187

31903188
renderTemplateField.value = false;
31913189
await flushPromises();
31923190

31933191
expect(form.meta.value.valid).toBe(true);
3194-
expect(form.getAllPathStates()).toMatchObject([{ id: 0, path: 'foo' }]);
3192+
expect(form.getAllPathStates()).toMatchObject([{ id: [0], path: 'foo' }]);
31953193
});
31963194

31973195
test('handles onSubmit with generic object from zod schema', async () => {

packages/vee-validate/tests/useField.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,60 @@ describe('useField()', () => {
10091009
expect(field.errors.value).toHaveLength(0);
10101010
});
10111011

1012+
// #5021
1013+
test('meta object syncs between multiple useField calls for the same path within a form', async () => {
1014+
let parentMeta!: FieldContext['meta'];
1015+
let childMeta!: FieldContext['meta'];
1016+
let childHandleBlur!: FieldContext['handleBlur'];
1017+
1018+
const ChildComponent = defineComponent({
1019+
setup() {
1020+
const { meta, handleBlur } = useField('field');
1021+
childMeta = meta;
1022+
childHandleBlur = handleBlur;
1023+
1024+
return { meta };
1025+
},
1026+
template: `<span id="child-touched">{{ meta.touched }}</span>`,
1027+
});
1028+
1029+
mountWithHoc({
1030+
components: { ChildComponent },
1031+
setup() {
1032+
useForm();
1033+
const { meta } = useField('field');
1034+
parentMeta = meta;
1035+
1036+
return { meta };
1037+
},
1038+
template: `
1039+
<span id="parent-touched">{{ meta.touched }}</span>
1040+
<ChildComponent />
1041+
`,
1042+
});
1043+
1044+
await flushPromises();
1045+
1046+
const parentTouched = document.querySelector('#parent-touched');
1047+
const childTouched = document.querySelector('#child-touched');
1048+
1049+
// Both should start as not touched
1050+
expect(parentTouched?.textContent).toBe('false');
1051+
expect(childTouched?.textContent).toBe('false');
1052+
expect(parentMeta.touched).toBe(false);
1053+
expect(childMeta.touched).toBe(false);
1054+
1055+
// Touch from child
1056+
childHandleBlur();
1057+
await flushPromises();
1058+
1059+
// Both should be touched now (meta is shared)
1060+
expect(parentMeta.touched).toBe(true);
1061+
expect(childMeta.touched).toBe(true);
1062+
expect(parentTouched?.textContent).toBe('true');
1063+
expect(childTouched?.textContent).toBe('true');
1064+
});
1065+
10121066
// #4603
10131067
test('should not remove field value if field with same path was created between scheduling and execution of previous field unset operation', async () => {
10141068
vi.useFakeTimers();

0 commit comments

Comments
 (0)