Skip to content

Commit 7554d4a

Browse files
committed
fix: only validate when one of the setters is ran closes #4017
1 parent be64c38 commit 7554d4a

File tree

6 files changed

+132
-63
lines changed

6 files changed

+132
-63
lines changed

.changeset/brave-jars-rescue.md

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 field array triggering validation when an item is removed

packages/vee-validate/src/types/forms.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,6 @@ export interface PrivateFormContext<TValues extends GenericObject = GenericObjec
228228
errors: ComputedRef<FormErrors<TValues>>;
229229
meta: ComputedRef<FormMeta<TValues>>;
230230
isSubmitting: Ref<boolean>;
231-
isResetting: Ref<boolean>;
232231
keepValuesOnUnmount: MaybeRef<boolean>;
233232
validateSchema?: (mode: SchemaValidationMode) => Promise<FormValidationResult<TValues, TOutput>>;
234233
validate(opts?: Partial<ValidationOptions>): Promise<FormValidationResult<TValues, TOutput>>;

packages/vee-validate/src/useField.ts

Lines changed: 33 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ import {
99
Ref,
1010
ComponentInternalInstance,
1111
onBeforeUnmount,
12-
nextTick,
13-
WatchStopHandle,
12+
warn,
1413
} from 'vue';
1514
import { klona as deepCopy } from 'klona/full';
1615
import { validate as validateValue } from './validate';
@@ -220,7 +219,7 @@ function _useField<TValue = unknown>(
220219
function handleChange(e: unknown, shouldValidate = true) {
221220
const newValue = normalizeEventValue(e) as TValue;
222221

223-
value.value = newValue;
222+
setValue(newValue, false);
224223
if (!validateOnValueUpdate && shouldValidate) {
225224
validateWithStateMutation();
226225
}
@@ -243,35 +242,7 @@ function _useField<TValue = unknown>(
243242
meta.touched = isTouched;
244243
}
245244

246-
let unwatchValue: WatchStopHandle;
247-
let lastWatchedValue = deepCopy(value.value);
248-
function watchValue() {
249-
unwatchValue = watch(
250-
value,
251-
(val, oldVal) => {
252-
if (form?.isResetting.value) {
253-
return;
254-
}
255-
256-
if (isEqual(val, oldVal) && isEqual(val, lastWatchedValue)) {
257-
return;
258-
}
259-
260-
const validateFn = validateOnValueUpdate ? validateWithStateMutation : validateValidStateOnly;
261-
validateFn();
262-
lastWatchedValue = deepCopy(val);
263-
},
264-
{
265-
deep: true,
266-
}
267-
);
268-
}
269-
270-
watchValue();
271-
272245
function resetField(state?: Partial<FieldState<TValue>>) {
273-
unwatchValue?.();
274-
275246
const newValue = state && 'value' in state ? (state.value as TValue) : initialValue.value;
276247

277248
setState({
@@ -284,26 +255,50 @@ function _useField<TValue = unknown>(
284255
meta.pending = false;
285256
meta.validated = false;
286257
validateValidStateOnly();
287-
288-
// need to watch at next tick to avoid triggering the value watcher
289-
nextTick(() => {
290-
watchValue();
291-
});
292258
}
293259

294-
function setValue(newValue: TValue) {
260+
function setValue(newValue: TValue, validate = true) {
295261
value.value = newValue;
262+
if (!validate) {
263+
return;
264+
}
265+
266+
const validateFn = validateOnValueUpdate ? validateWithStateMutation : validateValidStateOnly;
267+
validateFn();
296268
}
297269

298270
function setErrors(errors: string[] | string) {
299271
setState({ errors: Array.isArray(errors) ? errors : [errors] });
300272
}
301273

274+
const valueProxy = computed({
275+
get() {
276+
return value.value;
277+
},
278+
set(newValue: TValue) {
279+
setValue(newValue, validateOnValueUpdate);
280+
},
281+
});
282+
283+
if (__DEV__) {
284+
watch(
285+
valueProxy,
286+
(value, oldValue) => {
287+
if (value === oldValue && isEqual(value, oldValue)) {
288+
warn(
289+
'Detected a possible deep change on field `value` ref, for nested changes please either set the entire ref value or use `setValue` or `handleChange`.'
290+
);
291+
}
292+
},
293+
{ deep: true }
294+
);
295+
}
296+
302297
const field: PrivateFieldContext<TValue> = {
303298
id,
304299
name,
305300
label,
306-
value,
301+
value: valueProxy,
307302
meta,
308303
errors,
309304
errorMessage,

packages/vee-validate/src/useForm.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,6 @@ export function useForm<
116116
// Prevents fields from double resetting their values, which causes checkboxes to toggle their initial value
117117
let FIELD_ID_COUNTER = 0;
118118

119-
const isResetting = ref(false);
120-
121119
// If the form is currently submitting
122120
const isSubmitting = ref(false);
123121

@@ -479,7 +477,6 @@ export function useForm<
479477
submitCount,
480478
meta,
481479
isSubmitting,
482-
isResetting,
483480
fieldArrays,
484481
keepValuesOnUnmount,
485482
validateSchema: unref(schema) ? validateSchema : undefined,
@@ -593,8 +590,6 @@ export function useForm<
593590
* Resets all fields
594591
*/
595592
function resetForm(resetState?: Partial<FormState<TValues>>) {
596-
isResetting.value = true;
597-
598593
const newValues = resetState?.values ? resetState.values : originalInitialValues.value;
599594
setInitialValues(newValues);
600595
setValues(newValues);
@@ -610,7 +605,6 @@ export function useForm<
610605
submitCount.value = resetState?.submitCount || 0;
611606
nextTick(() => {
612607
validate({ mode: 'silent' });
613-
isResetting.value = false;
614608
});
615609
}
616610

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,90 @@ test('clears old errors path when last item is removed and value update validati
722722
expect(errorList.children).toHaveLength(0);
723723
});
724724

725+
// 4017
726+
test('keeps the errors intact if an item was removed in the middle of the list', async () => {
727+
defineRule('required', (v: any) => (v ? true : REQUIRED_MESSAGE));
728+
const InputField = defineComponent({
729+
props: {
730+
rules: {
731+
type: null,
732+
required: true,
733+
},
734+
name: {
735+
type: String,
736+
required: true,
737+
},
738+
label: String,
739+
type: { type: String, default: 'text' },
740+
},
741+
setup(props) {
742+
const { value, handleChange, errors } = useField(toRef(props, 'name'), props.rules, {
743+
label: props.label,
744+
});
745+
746+
return {
747+
value,
748+
errors,
749+
handleChange,
750+
};
751+
},
752+
template: `
753+
<label :for="name">{{ label }}</label>
754+
<input :type="type" :name="name" :value="value" @input="handleChange" />
755+
<span>{{ errors[0] }}</span>
756+
`,
757+
});
758+
759+
mountWithHoc({
760+
components: {
761+
InputField,
762+
},
763+
setup() {
764+
const initialValues = {
765+
users: ['', '', ''],
766+
};
767+
768+
const schema = yup.string().required();
769+
770+
return {
771+
schema,
772+
initialValues,
773+
};
774+
},
775+
template: `
776+
<VForm :initial-values="initialValues" v-slot="{ errors }">
777+
<FieldArray name="users" v-slot="{ remove, push, fields }">
778+
<fieldset v-for="(field, idx) in fields" :key="field.key">
779+
<legend>User #{{ idx }}</legend>
780+
<label :for="'name_' + idx">Name</label>
781+
<InputField :name="'users[' + idx + ']'" :rules="schema" />
782+
783+
<button class="remove" type="button" @click="remove(idx)">X</button>
784+
</fieldset>
785+
</FieldArray>
786+
787+
788+
<ul class="errors">
789+
<li v-for="error in errors">{{ error }}</li>
790+
</ul>
791+
792+
<button class="submit" type="submit">Submit</button>
793+
</VForm>
794+
`,
795+
});
796+
797+
await flushPromises();
798+
const errorList = document.querySelector('ul') as HTMLUListElement;
799+
const removeBtnAt = (idx: number) => document.querySelectorAll('.remove')[idx] as HTMLButtonElement; // remove the second item
800+
801+
await flushPromises();
802+
expect(errorList.children).toHaveLength(0);
803+
removeBtnAt(1).click();
804+
await flushPromises();
805+
806+
expect(errorList.children).toHaveLength(0);
807+
});
808+
725809
test('moves items around the array with move()', async () => {
726810
const onSubmit = vi.fn();
727811
mountWithHoc({

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

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,16 @@ describe('useField()', () => {
2929
expect(error?.textContent).toBe(REQUIRED_MESSAGE);
3030
});
3131

32-
// #3926
33-
test('validates when nested value changes', async () => {
32+
test('warns when nested value changes', async () => {
33+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {
34+
// NOOP
35+
});
36+
3437
mountWithHoc({
3538
setup() {
36-
const { value, errorMessage } = useField<any>(
37-
'field',
38-
val => {
39-
if (!val?.name) {
40-
return REQUIRED_MESSAGE;
41-
}
42-
43-
return true;
44-
},
45-
{
46-
initialValue: { name: 'test' },
47-
}
48-
);
39+
const { value, errorMessage } = useField<any>('field', undefined, {
40+
initialValue: { name: 'test' },
41+
});
4942

5043
onMounted(() => {
5144
value.value.name = '';
@@ -61,10 +54,9 @@ describe('useField()', () => {
6154
`,
6255
});
6356

64-
const error = document.querySelector('span');
65-
6657
await flushPromises();
67-
expect(error?.textContent).toBe(REQUIRED_MESSAGE);
58+
expect(spy).toHaveBeenCalled();
59+
spy.mockRestore();
6860
});
6961

7062
test('valid flag is correct after reset', async () => {

0 commit comments

Comments
 (0)