diff --git a/.changeset/fix-5072-dirty-meta-lost.md b/.changeset/fix-5072-dirty-meta-lost.md new file mode 100644 index 000000000..44f17f078 --- /dev/null +++ b/.changeset/fix-5072-dirty-meta-lost.md @@ -0,0 +1,5 @@ +--- +"vee-validate": patch +--- + +Preserve dirty meta when field component mounts after programmatic changes (#5072) diff --git a/packages/vee-validate/src/useFieldState.ts b/packages/vee-validate/src/useFieldState.ts index 4b8211d43..d6f2a1d63 100644 --- a/packages/vee-validate/src/useFieldState.ts +++ b/packages/vee-validate/src/useFieldState.ts @@ -159,7 +159,15 @@ export function _useFieldValue( // 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); + } // otherwise use a computed setter that triggers the `setFieldValue` const value = computed({ get() { diff --git a/packages/vee-validate/src/useForm.ts b/packages/vee-validate/src/useForm.ts index f5e7329b8..a849e20c3 100644 --- a/packages/vee-validate/src/useForm.ts +++ b/packages/vee-validate/src/useForm.ts @@ -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, pending: false, valid: true, validated: !!initialErrors[pathValue]?.length, diff --git a/packages/vee-validate/tests/useForm.spec.ts b/packages/vee-validate/tests/useForm.spec.ts index 09671b183..6c98038bc 100644 --- a/packages/vee-validate/tests/useForm.spec.ts +++ b/packages/vee-validate/tests/useForm.spec.ts @@ -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: ` +
+ +
+ `, + }); + + 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: ` +
+ +
+ `, + }); + + 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); + }); });