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

Preserve dirty meta when field component mounts after programmatic changes (#5072)
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 changeset note mentions preserving dirty meta, but this PR also changes touched preservation on late mount. Consider updating the changeset description to reflect both behaviors so the release note matches the shipped fix.

Suggested change
Preserve dirty meta when field component mounts after programmatic changes (#5072)
Preserve dirty and touched meta when field component mounts after programmatic changes (#5072)

Copilot uses AI. Check for mistakes.
10 changes: 9 additions & 1 deletion packages/vee-validate/src/useFieldState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,15 @@ export function _useFieldValue<TValue = unknown>(
// prioritize model value over form values
// #3429
const currentValue = resolveModelValue(modelValue, form, initialValue, path);
form.stageInitialValue(unref(path), currentValue, true);
// Skip staging if the path already has an initial value on the form and no explicit
// modelValue was provided to this field. This preserves dirty state when a field
// component mounts after the value was changed programmatically (e.g., via setFieldValue).
// Without this check, stageInitialValue would overwrite the original initial value with
// the current (dirty) value, making dirty=false (#5072).
const existingInitial = getFromPath(form.initialValues.value, unref(path));
if (existingInitial === undefined || modelValue !== undefined) {
form.stageInitialValue(unref(path), currentValue, true);
}
Comment on lines +167 to +170
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.

existingInitial === undefined can't distinguish between “path missing from initialValues” vs “path present with an explicit initial value of undefined”. In that latter case this will still call stageInitialValue and can overwrite the intended initial state, potentially reintroducing the dirty-meta regression for undefined initial values. Consider checking path presence explicitly (e.g., use getFromPath(..., IS_ABSENT) and compare against the sentinel, plus a dedicated presence check for [nonNested] paths) rather than comparing the resolved value to undefined.

Copilot uses AI. Check for mistakes.
// otherwise use a computed setter that triggers the `setFieldValue`
const value = computed<TValue>({
get() {
Expand Down
6 changes: 5 additions & 1 deletion packages/vee-validate/src/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,15 @@ export function useForm<
UNSET_BATCH.splice(unsetBatchIndex, 1);
}

// Preserve touched state from an existing path state that was created
// before this field component mounted (e.g., via setFieldValue) (#5072)
const existingTouched = pathStateExists ? pathStateExists.touched : false;

const id = FIELD_ID_COUNTER++;
const state = reactive({
id,
path,
touched: false,
touched: existingTouched,
Comment on lines +290 to +298
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.

In the #5072 scenario, setFieldValue() creates a path state before the field mounts, and then useFieldState() calls createPathState() again on mount. For non-checkbox/radio fields, createPathState() always creates/pushes a new reactive state even when pathStateExists is truthy, leaving the pre-mount state orphaned in pathStates (can’t be removed because it has a different id). Copying touched mitigates one symptom, but the duplicate/orphan state is still a correctness and memory concern and can also drop other programmatically-set meta (e.g. errors/validated). Consider reusing/updating the existing state when one already exists (similar to the checkbox/radio branch), or explicitly removing/replacing the old state before pushing the new one.

Copilot uses AI. Check for mistakes.
pending: false,
valid: true,
validated: !!initialErrors[pathValue]?.length,
Expand Down
83 changes: 83 additions & 0 deletions packages/vee-validate/tests/useForm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1489,4 +1489,87 @@ describe('useForm()', () => {
form.setValues({ file: f2 });
expect(form.values.file).toEqual(f2);
});

// #5072
test('preserves dirty meta when field component mounts after programmatic value changes', async () => {
let form!: FormContext<{ name: string }>;
const showField = ref(false);

mountWithHoc({
setup() {
form = useForm({
initialValues: { name: '' },
});

return {
showField,
};
},
template: `
<div>
<template v-if="showField">
<Field name="name" />
</template>
</div>
`,
});

await flushPromises();

// Set the field value programmatically before the field component is mounted
form.setFieldValue('name', 'John');
await flushPromises();

// Verify the field is dirty before mount
expect(form.isFieldDirty('name')).toBe(true);
expect(form.meta.value.dirty).toBe(true);

// Now mount the field component
showField.value = true;
await flushPromises();

// The dirty state should be preserved after the field component mounts
expect(form.values.name).toBe('John');
expect(form.isFieldDirty('name')).toBe(true);
expect(form.meta.value.dirty).toBe(true);
});

// #5072
test('preserves touched meta when field component mounts after programmatic changes', async () => {
let form!: FormContext<{ name: string }>;
const showField = ref(false);

mountWithHoc({
setup() {
form = useForm({
initialValues: { name: '' },
});

return {
showField,
};
},
template: `
<div>
<template v-if="showField">
<Field name="name" />
</template>
</div>
`,
});

await flushPromises();

// Set the field as touched programmatically before the field component is mounted
form.setFieldValue('name', 'John');
form.setFieldTouched('name', true);
await flushPromises();

// Now mount the field component
showField.value = true;
await flushPromises();

// The touched state should be preserved after the field component mounts
expect(form.isFieldTouched('name')).toBe(true);
});
});
Loading