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-5021-usefield-meta-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"vee-validate": patch
---

Fix useField meta object syncing regression from v4.11.8 (#5021)
7 changes: 2 additions & 5 deletions packages/vee-validate/src/useField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,7 @@

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;
}
Expand All @@ -405,7 +402,7 @@
if (Array.isArray(pathState.id)) {
pathState.id.splice(pathState.id.indexOf(field.id), 1);
}
} else {
} else if (pathState?.multiple || pathState?.fieldsCount <= 1) {

Check failure on line 405 in packages/vee-validate/src/useField.ts

View workflow job for this annotation

GitHub Actions / typecheck

'pathState.fieldsCount' is possibly 'undefined'.
form.unsetPathValue(toValue(name));
}

Expand Down
15 changes: 9 additions & 6 deletions packages/vee-validate/src/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,14 @@ export function useForm<
config?: Partial<PathStateConfig<TOutput[TPath]>>,
): PathState<TValues[TPath], TOutput[TPath]> {
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);
Expand All @@ -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) {
Expand Down Expand Up @@ -576,7 +579,7 @@ export function useForm<
validateField(path, { mode: 'silent', warn: false });
});

if (pathState.multiple && pathState.fieldsCount) {
if (pathState.fieldsCount) {
pathState.fieldsCount--;
}

Expand All @@ -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();
Expand Down
8 changes: 3 additions & 5 deletions packages/vee-validate/tests/Form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3181,17 +3181,15 @@ test('removes proper pathState when field is unmounting', async () => {
renderTemplateField.value = true;
await flushPromises();

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

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' }]);
});

test('handles onSubmit with generic object from zod schema', async () => {
Expand Down
54 changes: 54 additions & 0 deletions packages/vee-validate/tests/useField.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,60 @@ describe('useField()', () => {
expect(field.errors.value).toHaveLength(0);
});

// #5021
test('meta object syncs between multiple useField calls for the same path within a form', async () => {
let parentMeta!: FieldContext['meta'];
let childMeta!: FieldContext['meta'];
let childHandleBlur!: FieldContext['handleBlur'];

const ChildComponent = defineComponent({
setup() {
const { meta, handleBlur } = useField('field');
childMeta = meta;
childHandleBlur = handleBlur;

return { meta };
},
template: `<span id="child-touched">{{ meta.touched }}</span>`,
});

mountWithHoc({
components: { ChildComponent },
setup() {
useForm();
const { meta } = useField('field');
parentMeta = meta;

return { meta };
},
template: `
<span id="parent-touched">{{ meta.touched }}</span>
<ChildComponent />
`,
});

await flushPromises();

const parentTouched = document.querySelector('#parent-touched');
const childTouched = document.querySelector('#child-touched');

// Both should start as not touched
expect(parentTouched?.textContent).toBe('false');
expect(childTouched?.textContent).toBe('false');
expect(parentMeta.touched).toBe(false);
expect(childMeta.touched).toBe(false);

// Touch from child
childHandleBlur();
await flushPromises();

// Both should be touched now (meta is shared)
expect(parentMeta.touched).toBe(true);
expect(childMeta.touched).toBe(true);
expect(parentTouched?.textContent).toBe('true');
expect(childTouched?.textContent).toBe('true');
});

// #4603
test('should not remove field value if field with same path was created between scheduling and execution of previous field unset operation', async () => {
vi.useFakeTimers();
Expand Down
Loading