Skip to content

Commit 8c82079

Browse files
authored
fix: handle validation races for async validations (#3908)
1 parent 91e97aa commit 8c82079

File tree

5 files changed

+221
-100
lines changed

5 files changed

+221
-100
lines changed

packages/vee-validate/src/useField.ts

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
resolveNextCheckboxValue,
3737
isYupValidator,
3838
applyModelModifiers,
39+
withLatest,
3940
} from './utils';
4041
import { isCallable } from '../../shared';
4142
import { FieldContextKey, FormContextKey, IS_ABSENT } from './symbols';
@@ -147,42 +148,47 @@ function _useField<TValue = unknown>(
147148
});
148149
}
149150

150-
async function validateWithStateMutation(): Promise<ValidationResult> {
151-
meta.pending = true;
152-
meta.validated = true;
153-
const result = await validateCurrentValue('validated-only');
154-
if (markedForRemoval) {
155-
result.valid = true;
156-
result.errors = [];
157-
}
151+
const validateWithStateMutation = withLatest(
152+
async () => {
153+
meta.pending = true;
154+
meta.validated = true;
155+
156+
return validateCurrentValue('validated-only');
157+
},
158+
result => {
159+
if (markedForRemoval) {
160+
result.valid = true;
161+
result.errors = [];
162+
}
158163

159-
setState({ errors: result.errors });
160-
meta.pending = false;
164+
setState({ errors: result.errors });
165+
meta.pending = false;
161166

162-
return result;
163-
}
164-
165-
async function validateValidStateOnly(): Promise<ValidationResult> {
166-
const result = await validateCurrentValue('silent');
167-
if (markedForRemoval) {
168-
result.valid = true;
167+
return result;
169168
}
169+
);
170170

171-
meta.valid = result.valid;
171+
const validateValidStateOnly = withLatest(
172+
async () => {
173+
return validateCurrentValue('silent');
174+
},
175+
result => {
176+
if (markedForRemoval) {
177+
result.valid = true;
178+
}
172179

173-
return result;
174-
}
180+
meta.valid = result.valid;
175181

176-
function validate(opts?: Partial<ValidationOptions>) {
177-
if (!opts?.mode || opts?.mode === 'force') {
178-
return validateWithStateMutation();
182+
return result;
179183
}
184+
);
180185

181-
if (opts?.mode === 'validated-only') {
182-
return validateWithStateMutation();
186+
function validate(opts?: Partial<ValidationOptions>) {
187+
if (opts?.mode === 'silent') {
188+
return validateValidStateOnly();
183189
}
184190

185-
return validateValidStateOnly();
191+
return validateWithStateMutation();
186192
}
187193

188194
// Common input/change event handler

packages/vee-validate/src/useForm.ts

Lines changed: 65 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
isFormSubmitEvent,
4747
debounceAsync,
4848
isEmptyContainer,
49+
withLatest,
4950
} from './utils';
5051
import { FormContextKey } from './symbols';
5152
import { validateYupSchema, validateObjectSchema } from './validate';
@@ -156,6 +157,70 @@ export function useForm<TValues extends Record<string, any> = Record<string, any
156157
const meta = useFormMeta(fieldsByPath, formValues, originalInitialValues, errors);
157158

158159
const schema = opts?.validationSchema;
160+
161+
/**
162+
* Batches validation runs in 5ms batches
163+
* Must have two distinct batch queues to make sure they don't override each other settings #3783
164+
*/
165+
const debouncedSilentValidation = debounceAsync(_validateSchema, 5);
166+
const debouncedValidation = debounceAsync(_validateSchema, 5);
167+
168+
const validateSchema = withLatest(
169+
async (mode: SchemaValidationMode) => {
170+
return (await mode) === 'silent' ? debouncedSilentValidation() : debouncedValidation();
171+
},
172+
(formResult, [mode]) => {
173+
// fields by id lookup
174+
const fieldsById = formCtx.fieldsByPath.value || {};
175+
// errors fields names, we need it to also check if custom errors are updated
176+
const currentErrorsPaths = keysOf(formCtx.errorBag.value);
177+
// collect all the keys from the schema and all fields
178+
// this ensures we have a complete keymap of all the fields
179+
const paths = [
180+
...new Set([...keysOf(formResult.results), ...keysOf(fieldsById), ...currentErrorsPaths]),
181+
] as string[];
182+
183+
// aggregates the paths into a single result object while applying the results on the fields
184+
return paths.reduce(
185+
(validation, path) => {
186+
const field = fieldsById[path];
187+
const messages = (formResult.results[path] || { errors: [] as string[] }).errors;
188+
const fieldResult = {
189+
errors: messages,
190+
valid: !messages.length,
191+
};
192+
validation.results[path as keyof TValues] = fieldResult;
193+
if (!fieldResult.valid) {
194+
validation.errors[path as keyof TValues] = fieldResult.errors[0];
195+
}
196+
197+
// field not rendered
198+
if (!field) {
199+
setFieldError(path, messages);
200+
201+
return validation;
202+
}
203+
204+
// always update the valid flag regardless of the mode
205+
applyFieldMutation(field, f => (f.meta.valid = fieldResult.valid));
206+
if (mode === 'silent') {
207+
return validation;
208+
}
209+
210+
const wasValidated = Array.isArray(field) ? field.some(f => f.meta.validated) : field.meta.validated;
211+
if (mode === 'validated-only' && !wasValidated) {
212+
return validation;
213+
}
214+
215+
applyFieldMutation(field, f => f.setState({ errors: fieldResult.errors }));
216+
217+
return validation;
218+
},
219+
{ valid: formResult.valid, results: {}, errors: {} } as FormValidationResult<TValues>
220+
);
221+
}
222+
);
223+
159224
const formCtx: PrivateFormContext<TValues> = {
160225
formId,
161226
fieldsByPath,
@@ -660,66 +725,6 @@ export function useForm<TValues extends Record<string, any> = Record<string, any
660725
return formResult;
661726
}
662727

663-
/**
664-
* Batches validation runs in 5ms batches
665-
* Must have two distinct batch queues to make sure they don't override each other settings #3783
666-
*/
667-
const debouncedSilentValidation = debounceAsync(_validateSchema, 5);
668-
const debouncedValidation = debounceAsync(_validateSchema, 5);
669-
670-
async function validateSchema(mode: SchemaValidationMode): Promise<FormValidationResult<TValues>> {
671-
const formResult = await (mode === 'silent' ? debouncedSilentValidation() : debouncedValidation());
672-
673-
// fields by id lookup
674-
const fieldsById = formCtx.fieldsByPath.value || {};
675-
// errors fields names, we need it to also check if custom errors are updated
676-
const currentErrorsPaths = keysOf(formCtx.errorBag.value);
677-
// collect all the keys from the schema and all fields
678-
// this ensures we have a complete keymap of all the fields
679-
const paths = [
680-
...new Set([...keysOf(formResult.results), ...keysOf(fieldsById), ...currentErrorsPaths]),
681-
] as string[];
682-
683-
// aggregates the paths into a single result object while applying the results on the fields
684-
return paths.reduce(
685-
(validation, path) => {
686-
const field = fieldsById[path];
687-
const messages = (formResult.results[path] || { errors: [] as string[] }).errors;
688-
const fieldResult = {
689-
errors: messages,
690-
valid: !messages.length,
691-
};
692-
validation.results[path as keyof TValues] = fieldResult;
693-
if (!fieldResult.valid) {
694-
validation.errors[path as keyof TValues] = fieldResult.errors[0];
695-
}
696-
697-
// field not rendered
698-
if (!field) {
699-
setFieldError(path, messages);
700-
701-
return validation;
702-
}
703-
704-
// always update the valid flag regardless of the mode
705-
applyFieldMutation(field, f => (f.meta.valid = fieldResult.valid));
706-
if (mode === 'silent') {
707-
return validation;
708-
}
709-
710-
const wasValidated = Array.isArray(field) ? field.some(f => f.meta.validated) : field.meta.validated;
711-
if (mode === 'validated-only' && !wasValidated) {
712-
return validation;
713-
}
714-
715-
applyFieldMutation(field, f => f.setState({ errors: fieldResult.errors }));
716-
717-
return validation;
718-
},
719-
{ valid: formResult.valid, results: {}, errors: {} } as FormValidationResult<TValues>
720-
);
721-
}
722-
723728
const submitForm = handleSubmit((_, { evt }) => {
724729
if (isFormSubmitEvent(evt)) {
725730
evt.target.submit();

packages/vee-validate/src/utils/common.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,24 @@ export function applyModelModifiers(value: unknown, modifiers: unknown) {
244244

245245
return value;
246246
}
247+
248+
export function withLatest<TFunction extends (...args: any[]) => Promise<any>, TResult = ReturnType<TFunction>>(
249+
fn: TFunction,
250+
onDone: (result: Awaited<TResult>, args: Parameters<TFunction>) => void
251+
) {
252+
let latestRun: Promise<TResult> | undefined;
253+
254+
return async function runLatest(...args: Parameters<TFunction>) {
255+
const pending = fn(...args);
256+
latestRun = pending;
257+
const result = await pending;
258+
if (pending !== latestRun) {
259+
return result;
260+
}
261+
262+
latestRun = undefined;
263+
onDone(result, args);
264+
265+
return result;
266+
};
267+
}

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useField, useForm } from '@/vee-validate';
2-
import { defineComponent, onMounted, ref } from 'vue';
2+
import { defineComponent, nextTick, onMounted, ref } from 'vue';
33
import { mountWithHoc, setValue, flushPromises } from './helpers';
44

55
describe('useField()', () => {
@@ -656,4 +656,54 @@ describe('useField()', () => {
656656
await flushPromises();
657657
expect(input?.value).toBe('321');
658658
});
659+
660+
// #3906
661+
test('only latest validation run messages are used', async () => {
662+
function validator(value: string | undefined) {
663+
if (!value) {
664+
return true;
665+
}
666+
667+
if (value.toLowerCase().startsWith('b')) {
668+
return 'not b';
669+
}
670+
671+
return new Promise<string | boolean>(resolve => {
672+
setTimeout(() => {
673+
if (value.toLowerCase().startsWith('a')) {
674+
resolve('not a');
675+
return;
676+
}
677+
678+
resolve(true);
679+
}, 100);
680+
});
681+
}
682+
683+
mountWithHoc({
684+
setup() {
685+
const { value, errorMessage } = useField<string>('field', validator);
686+
687+
return {
688+
value,
689+
errorMessage,
690+
};
691+
},
692+
template: `
693+
<input name="field" v-model="value" />
694+
<span>{{ errorMessage }}</span>
695+
`,
696+
});
697+
698+
const input = document.querySelector('input');
699+
const error = document.querySelector('span');
700+
701+
setValue(input as any, 'a');
702+
await flushPromises();
703+
setValue(input as any, 'b');
704+
await flushPromises();
705+
jest.advanceTimersByTime(200);
706+
await flushPromises();
707+
expect(error?.textContent).toBe('not b');
708+
});
659709
});

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

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -452,21 +452,60 @@ describe('useForm()', () => {
452452
});
453453
});
454454

455-
test('Validates the field model immediately and when it changes', async () => {
456-
await runInSetup(async () => {
457-
const { errors, useFieldModel } = useForm();
458-
const model = useFieldModel('test');
459-
460-
await flushPromises();
461-
expect(errors.value.test).toBe(REQUIRED_MESSAGE);
455+
// #3906
456+
test('only latest schema validation run messages are used', async () => {
457+
function validator(value: string | undefined) {
458+
if (!value) {
459+
return true;
460+
}
461+
462+
if (value.toLowerCase().startsWith('b')) {
463+
return 'not b';
464+
}
465+
466+
return new Promise<string | boolean>(resolve => {
467+
setTimeout(() => {
468+
if (value.toLowerCase().startsWith('a')) {
469+
resolve('not a');
470+
return;
471+
}
472+
473+
resolve(true);
474+
}, 100);
475+
});
476+
}
462477

463-
model.value = 'hello';
464-
await flushPromises();
465-
expect(errors.value.test).toBe('');
478+
mountWithHoc({
479+
setup() {
480+
const { errors, useFieldModel } = useForm({
481+
validationSchema: {
482+
test: validator,
483+
},
484+
});
485+
const model = useFieldModel('test');
466486

467-
model.value = '';
468-
await flushPromises();
469-
expect(errors.value.test).toBe(REQUIRED_MESSAGE);
487+
return {
488+
model,
489+
errors,
490+
};
491+
},
492+
template: `
493+
<input name="field" v-model="model" />
494+
<span>{{ errors.test }}</span>
495+
`,
470496
});
497+
498+
await flushPromises();
499+
500+
const input = document.querySelector('input');
501+
const error = document.querySelector('span');
502+
503+
setValue(input as any, 'a');
504+
await flushPromises();
505+
setValue(input as any, 'b');
506+
await flushPromises();
507+
jest.advanceTimersByTime(200);
508+
await flushPromises();
509+
expect(error?.textContent).toBe('not b');
471510
});
472511
});

0 commit comments

Comments
 (0)