Skip to content

Commit 7344bc8

Browse files
logaretmclaude
andcommitted
fix: preserve dirty/touched meta on late mount (#5072)
When a field component mounts after its value was changed programmatically (e.g., via setFieldValue), the dirty and touched meta flags are now preserved instead of being reset. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7d8cc52 commit 7344bc8

4 files changed

Lines changed: 102 additions & 2 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+
Preserve dirty meta when field component mounts after programmatic changes (#5072)

packages/vee-validate/src/useFieldState.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,15 @@ export function _useFieldValue<TValue = unknown>(
159159
// prioritize model value over form values
160160
// #3429
161161
const currentValue = resolveModelValue(modelValue, form, initialValue, path);
162-
form.stageInitialValue(unref(path), currentValue, true);
162+
// Skip staging if the path already has an initial value on the form and no explicit
163+
// modelValue was provided to this field. This preserves dirty state when a field
164+
// component mounts after the value was changed programmatically (e.g., via setFieldValue).
165+
// Without this check, stageInitialValue would overwrite the original initial value with
166+
// the current (dirty) value, making dirty=false (#5072).
167+
const existingInitial = getFromPath(form.initialValues.value, unref(path));
168+
if (existingInitial === undefined || modelValue !== undefined) {
169+
form.stageInitialValue(unref(path), currentValue, true);
170+
}
163171
// otherwise use a computed setter that triggers the `setFieldValue`
164172
const value = computed<TValue>({
165173
get() {

packages/vee-validate/src/useForm.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,11 +287,15 @@ export function useForm<
287287
UNSET_BATCH.splice(unsetBatchIndex, 1);
288288
}
289289

290+
// Preserve touched state from an existing path state that was created
291+
// before this field component mounted (e.g., via setFieldValue) (#5072)
292+
const existingTouched = pathStateExists ? pathStateExists.touched : false;
293+
290294
const id = FIELD_ID_COUNTER++;
291295
const state = reactive({
292296
id,
293297
path,
294-
touched: false,
298+
touched: existingTouched,
295299
pending: false,
296300
valid: true,
297301
validated: !!initialErrors[pathValue]?.length,

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1489,4 +1489,87 @@ describe('useForm()', () => {
14891489
form.setValues({ file: f2 });
14901490
expect(form.values.file).toEqual(f2);
14911491
});
1492+
1493+
// #5072
1494+
test('preserves dirty meta when field component mounts after programmatic value changes', async () => {
1495+
let form!: FormContext<{ name: string }>;
1496+
const showField = ref(false);
1497+
1498+
mountWithHoc({
1499+
setup() {
1500+
form = useForm({
1501+
initialValues: { name: '' },
1502+
});
1503+
1504+
return {
1505+
showField,
1506+
};
1507+
},
1508+
template: `
1509+
<div>
1510+
<template v-if="showField">
1511+
<Field name="name" />
1512+
</template>
1513+
</div>
1514+
`,
1515+
});
1516+
1517+
await flushPromises();
1518+
1519+
// Set the field value programmatically before the field component is mounted
1520+
form.setFieldValue('name', 'John');
1521+
await flushPromises();
1522+
1523+
// Verify the field is dirty before mount
1524+
expect(form.isFieldDirty('name')).toBe(true);
1525+
expect(form.meta.value.dirty).toBe(true);
1526+
1527+
// Now mount the field component
1528+
showField.value = true;
1529+
await flushPromises();
1530+
1531+
// The dirty state should be preserved after the field component mounts
1532+
expect(form.values.name).toBe('John');
1533+
expect(form.isFieldDirty('name')).toBe(true);
1534+
expect(form.meta.value.dirty).toBe(true);
1535+
});
1536+
1537+
// #5072
1538+
test('preserves touched meta when field component mounts after programmatic changes', async () => {
1539+
let form!: FormContext<{ name: string }>;
1540+
const showField = ref(false);
1541+
1542+
mountWithHoc({
1543+
setup() {
1544+
form = useForm({
1545+
initialValues: { name: '' },
1546+
});
1547+
1548+
return {
1549+
showField,
1550+
};
1551+
},
1552+
template: `
1553+
<div>
1554+
<template v-if="showField">
1555+
<Field name="name" />
1556+
</template>
1557+
</div>
1558+
`,
1559+
});
1560+
1561+
await flushPromises();
1562+
1563+
// Set the field as touched programmatically before the field component is mounted
1564+
form.setFieldValue('name', 'John');
1565+
form.setFieldTouched('name', true);
1566+
await flushPromises();
1567+
1568+
// Now mount the field component
1569+
showField.value = true;
1570+
await flushPromises();
1571+
1572+
// The touched state should be preserved after the field component mounts
1573+
expect(form.isFieldTouched('name')).toBe(true);
1574+
});
14921575
});

0 commit comments

Comments
 (0)