Skip to content

Commit 55de0f3

Browse files
authored
fix: RenderProps deps validate (#218)
* fix: RenderProps deps validate * test: more test case * refactor: Move validate to field inner check * refactor: Re-order validate inner sep * fix: Validate should snapshot status * test: fix test case * test: fix test case
1 parent 12e8f5b commit 55de0f3

File tree

8 files changed

+155
-68
lines changed

8 files changed

+155
-68
lines changed

src/Field.tsx

Lines changed: 50 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ import {
2626
getValue,
2727
} from './utils/valueUtil';
2828

29-
export type ShouldUpdate =
29+
export type ShouldUpdate<Values = any> =
3030
| boolean
31-
| ((prevValues: Store, nextValues: Store, info: { source?: string }) => boolean);
31+
| ((prevValues: Values, nextValues: Values, info: { source?: string }) => boolean);
3232

3333
function requireUpdate(
3434
shouldUpdate: ShouldUpdate,
@@ -63,7 +63,7 @@ export interface InternalFieldProps<Values = any> {
6363
name?: InternalNamePath;
6464
normalize?: (value: StoreValue, prevValue: StoreValue, allValues: Store) => StoreValue;
6565
rules?: Rule[];
66-
shouldUpdate?: ShouldUpdate;
66+
shouldUpdate?: ShouldUpdate<Values>;
6767
trigger?: string;
6868
validateTrigger?: string | string[] | false;
6969
validateFirst?: boolean | 'parallel';
@@ -309,49 +309,62 @@ class Field extends React.Component<InternalFieldProps, FieldState> implements F
309309
}
310310
};
311311

312-
public validateRules = (options?: ValidateOptions) => {
313-
const { validateFirst = false, messageVariables } = this.props;
314-
const { triggerName } = (options || {}) as ValidateOptions;
312+
public validateRules = (options?: ValidateOptions): Promise<string[]> => {
313+
// We should fixed namePath & value to avoid developer change then by form function
315314
const namePath = this.getNamePath();
315+
const currentValue = this.getValue();
316316

317-
let filteredRules = this.getRules();
318-
if (triggerName) {
319-
filteredRules = filteredRules.filter((rule: RuleObject) => {
320-
const { validateTrigger } = rule;
321-
if (!validateTrigger) {
322-
return true;
323-
}
324-
const triggerList = toArray(validateTrigger);
325-
return triggerList.includes(triggerName);
326-
});
327-
}
317+
// Force change to async to avoid rule OOD under renderProps field
318+
const rootPromise = Promise.resolve().then(() => {
319+
if (!this.mounted) {
320+
return [];
321+
}
328322

329-
const promise = validateRules(
330-
namePath,
331-
this.getValue(),
332-
filteredRules,
333-
options,
334-
validateFirst,
335-
messageVariables,
336-
);
323+
const { validateFirst = false, messageVariables } = this.props;
324+
const { triggerName } = (options || {}) as ValidateOptions;
325+
326+
let filteredRules = this.getRules();
327+
if (triggerName) {
328+
filteredRules = filteredRules.filter((rule: RuleObject) => {
329+
const { validateTrigger } = rule;
330+
if (!validateTrigger) {
331+
return true;
332+
}
333+
const triggerList = toArray(validateTrigger);
334+
return triggerList.includes(triggerName);
335+
});
336+
}
337+
338+
const promise = validateRules(
339+
namePath,
340+
currentValue,
341+
filteredRules,
342+
options,
343+
validateFirst,
344+
messageVariables,
345+
);
346+
347+
promise
348+
.catch(e => e)
349+
.then((errors: string[] = []) => {
350+
if (this.validatePromise === rootPromise) {
351+
this.validatePromise = null;
352+
this.errors = errors;
353+
this.reRender();
354+
}
355+
});
356+
357+
return promise;
358+
});
359+
360+
this.validatePromise = rootPromise;
337361
this.dirty = true;
338-
this.validatePromise = promise;
339362
this.errors = [];
340363

341364
// Force trigger re-render since we need sync renderProps with new meta
342365
this.reRender();
343366

344-
promise
345-
.catch(e => e)
346-
.then((errors: string[] = []) => {
347-
if (this.validatePromise === promise) {
348-
this.validatePromise = null;
349-
this.errors = errors;
350-
this.reRender();
351-
}
352-
});
353-
354-
return promise;
367+
return rootPromise;
355368
};
356369

357370
public isFieldValidating = () => !!this.validatePromise;

src/interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ type Validator = (
4646
rule: RuleObject,
4747
value: StoreValue,
4848
callback: (error?: string) => void,
49-
) => Promise<void> | void;
49+
) => Promise<void | any> | void;
5050

5151
export type RuleRender = (form: FormInstance) => RuleObject;
5252

src/useForm.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,8 +578,11 @@ export class FormStore {
578578
});
579579

580580
// Notify dependencies children with parent update
581+
// We need delay to trigger validate in case Field is under render props
581582
const childrenFields = this.getDependencyChildrenFields(namePath);
582-
this.validateFields(childrenFields);
583+
if (childrenFields.length) {
584+
this.validateFields(childrenFields);
585+
}
583586

584587
this.notifyObservers(prevStore, childrenFields, {
585588
type: 'dependenciesUpdate',

tests/common/InfoField.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,21 @@ export const Input = ({ value = '', ...props }) => <input {...props} value={valu
1313
*/
1414
const InfoField: React.FC<InfoFieldProps> = ({ children, ...props }) => (
1515
<Field {...props}>
16-
{(control, { errors, validating }) => (
17-
<div>
18-
{children ? React.cloneElement(children, control) : <Input {...control} />}
19-
<ul className="errors">
20-
{errors.map((error, index) => (
21-
<li key={index}>{error}</li>
22-
))}
23-
</ul>
24-
{validating && <span className="validating" />}
25-
</div>
26-
)}
16+
{(control, info) => {
17+
const { errors, validating } = info;
18+
19+
return (
20+
<div>
21+
{children ? React.cloneElement(children, control) : <Input {...control} />}
22+
<ul className="errors">
23+
{errors.map((error, index) => (
24+
<li key={index}>{error}</li>
25+
))}
26+
</ul>
27+
{validating && <span className="validating" />}
28+
</div>
29+
);
30+
}}
2731
</Field>
2832
);
2933

tests/common/index.js renamed to tests/common/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function matchError(wrapper, error) {
2525
}
2626
}
2727

28-
export function getField(wrapper, index = 0) {
28+
export function getField(wrapper, index: string | number = 0) {
2929
if (typeof index === 'number') {
3030
return wrapper.find(Field).at(index);
3131
}

tests/dependencies.test.js

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ describe('Form.Dependencies', () => {
167167
const spy = jest.fn();
168168
const wrapper = mount(
169169
<Form>
170-
<Field dependencies={['field_1']} shouldUpdate={() => true}>
170+
<Field dependencies={['field_2']} shouldUpdate={() => true}>
171171
{() => {
172172
spy();
173173
return 'gogogo';
@@ -182,27 +182,20 @@ describe('Form.Dependencies', () => {
182182
</Form>,
183183
);
184184
expect(spy).toHaveBeenCalledTimes(1);
185-
await changeValue(getField(wrapper, 2), 'value2');
185+
await changeValue(getField(wrapper, 1), 'value1');
186186
// sync start
187187
// valueUpdate -> rerender by shouldUpdate
188188
// depsUpdate -> rerender by deps
189189
// [ react rerender once -> 2 ]
190190
// sync end
191-
// async start
192-
// validateFinish -> rerender by shouldUpdate
193-
// [ react rerender once -> 3 ]
194-
// async end
195-
expect(spy).toHaveBeenCalledTimes(3);
196-
await changeValue(getField(wrapper, 1), 'value1');
191+
expect(spy).toHaveBeenCalledTimes(2);
192+
193+
await changeValue(getField(wrapper, 2), 'value2');
197194
// sync start
198195
// valueUpdate -> rerender by shouldUpdate
199196
// depsUpdate -> rerender by deps
200-
// [ react rerender once -> 4 ]
197+
// [ react rerender once -> 3 ]
201198
// sync end
202-
// async start
203-
// validateFinish -> rerender by shouldUpdate
204-
// [ react rerender once -> 5 ]
205-
// async end
206-
expect(spy).toHaveBeenCalledTimes(5);
199+
expect(spy).toHaveBeenCalledTimes(3);
207200
});
208201
});

tests/legacy/dynamic-binding.test.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import React from 'react';
22
import { mount } from 'enzyme';
33
import Form, { Field } from '../../src';
44
import { Input } from '../common/InfoField';
5-
import { changeValue, getField } from '../common';
6-
import timeout from '../common/timeout';
75

86
describe('legacy.dynamic-binding', () => {
97
const getInput = (wrapper, id) => wrapper.find(id).last();

tests/validate.test.js renamed to tests/validate.test.tsx

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ describe('Form.Validate', () => {
498498

499499
[
500500
{ name: 'serialization', first: true, second: false, validateFirst: true },
501-
{ name: 'parallel', first: true, second: true, validateFirst: 'parallel' },
501+
{ name: 'parallel', first: true, second: true, validateFirst: 'parallel' as const },
502502
].forEach(({ name, first, second, validateFirst }) => {
503503
it(name, async () => {
504504
let ruleFirst = false;
@@ -532,6 +532,7 @@ describe('Form.Validate', () => {
532532
await changeValue(wrapper, 'test');
533533
await timeout();
534534

535+
wrapper.update();
535536
matchError(wrapper, 'failed first');
536537

537538
expect(ruleFirst).toEqual(first);
@@ -610,5 +611,80 @@ describe('Form.Validate', () => {
610611
wrapper.find('button').simulate('click');
611612
expect(renderProps.mock.calls[0][1]).toEqual(expect.objectContaining({ validating: true }));
612613
});
614+
615+
it('renderProps should use latest rules', async () => {
616+
let failedTriggerTimes = 0;
617+
let passedTriggerTimes = 0;
618+
619+
interface FormStore {
620+
username: string;
621+
password: string;
622+
}
623+
624+
const Demo = () => (
625+
<Form>
626+
<InfoField name="username" />
627+
<Form.Field<FormStore> shouldUpdate={(prev, cur) => prev.username !== cur.username}>
628+
{(_, __, { getFieldValue }) => {
629+
const value = getFieldValue('username');
630+
631+
if (value === 'removed') {
632+
return null;
633+
}
634+
635+
return (
636+
<InfoField
637+
dependencies={['username']}
638+
name="password"
639+
rules={
640+
value !== 'light'
641+
? [
642+
{
643+
validator: async () => {
644+
failedTriggerTimes += 1;
645+
throw new Error('Failed');
646+
},
647+
},
648+
]
649+
: [
650+
{
651+
validator: async () => {
652+
passedTriggerTimes += 1;
653+
},
654+
},
655+
]
656+
}
657+
/>
658+
);
659+
}}
660+
</Form.Field>
661+
</Form>
662+
);
663+
664+
const wrapper = mount(<Demo />);
665+
666+
expect(failedTriggerTimes).toEqual(0);
667+
expect(passedTriggerTimes).toEqual(0);
668+
669+
// Failed of second input
670+
await changeValue(getField(wrapper, 1), '');
671+
matchError(getField(wrapper, 2), true);
672+
673+
expect(failedTriggerTimes).toEqual(1);
674+
expect(passedTriggerTimes).toEqual(0);
675+
676+
// Changed first to trigger update
677+
await changeValue(getField(wrapper, 0), 'light');
678+
matchError(getField(wrapper, 2), false);
679+
680+
expect(failedTriggerTimes).toEqual(1);
681+
expect(passedTriggerTimes).toEqual(1);
682+
683+
// Remove should not trigger validate
684+
await changeValue(getField(wrapper, 0), 'removed');
685+
686+
expect(failedTriggerTimes).toEqual(1);
687+
expect(passedTriggerTimes).toEqual(1);
688+
});
613689
});
614690
/* eslint-enable no-template-curly-in-string */

0 commit comments

Comments
 (0)