diff --git a/src/Field.tsx b/src/Field.tsx index a05c0888..5dc84435 100644 --- a/src/Field.tsx +++ b/src/Field.tsx @@ -26,9 +26,9 @@ import { getValue, } from './utils/valueUtil'; -export type ShouldUpdate = +export type ShouldUpdate = | boolean - | ((prevValues: Store, nextValues: Store, info: { source?: string }) => boolean); + | ((prevValues: Values, nextValues: Values, info: { source?: string }) => boolean); function requireUpdate( shouldUpdate: ShouldUpdate, @@ -63,7 +63,7 @@ export interface InternalFieldProps { name?: InternalNamePath; normalize?: (value: StoreValue, prevValue: StoreValue, allValues: Store) => StoreValue; rules?: Rule[]; - shouldUpdate?: ShouldUpdate; + shouldUpdate?: ShouldUpdate; trigger?: string; validateTrigger?: string | string[] | false; validateFirst?: boolean | 'parallel'; @@ -309,49 +309,62 @@ class Field extends React.Component implements F } }; - public validateRules = (options?: ValidateOptions) => { - const { validateFirst = false, messageVariables } = this.props; - const { triggerName } = (options || {}) as ValidateOptions; + public validateRules = (options?: ValidateOptions): Promise => { + // We should fixed namePath & value to avoid developer change then by form function const namePath = this.getNamePath(); + const currentValue = this.getValue(); - let filteredRules = this.getRules(); - if (triggerName) { - filteredRules = filteredRules.filter((rule: RuleObject) => { - const { validateTrigger } = rule; - if (!validateTrigger) { - return true; - } - const triggerList = toArray(validateTrigger); - return triggerList.includes(triggerName); - }); - } + // Force change to async to avoid rule OOD under renderProps field + const rootPromise = Promise.resolve().then(() => { + if (!this.mounted) { + return []; + } - const promise = validateRules( - namePath, - this.getValue(), - filteredRules, - options, - validateFirst, - messageVariables, - ); + const { validateFirst = false, messageVariables } = this.props; + const { triggerName } = (options || {}) as ValidateOptions; + + let filteredRules = this.getRules(); + if (triggerName) { + filteredRules = filteredRules.filter((rule: RuleObject) => { + const { validateTrigger } = rule; + if (!validateTrigger) { + return true; + } + const triggerList = toArray(validateTrigger); + return triggerList.includes(triggerName); + }); + } + + const promise = validateRules( + namePath, + currentValue, + filteredRules, + options, + validateFirst, + messageVariables, + ); + + promise + .catch(e => e) + .then((errors: string[] = []) => { + if (this.validatePromise === rootPromise) { + this.validatePromise = null; + this.errors = errors; + this.reRender(); + } + }); + + return promise; + }); + + this.validatePromise = rootPromise; this.dirty = true; - this.validatePromise = promise; this.errors = []; // Force trigger re-render since we need sync renderProps with new meta this.reRender(); - promise - .catch(e => e) - .then((errors: string[] = []) => { - if (this.validatePromise === promise) { - this.validatePromise = null; - this.errors = errors; - this.reRender(); - } - }); - - return promise; + return rootPromise; }; public isFieldValidating = () => !!this.validatePromise; diff --git a/src/interface.ts b/src/interface.ts index 433ebe5e..97f51794 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -46,7 +46,7 @@ type Validator = ( rule: RuleObject, value: StoreValue, callback: (error?: string) => void, -) => Promise | void; +) => Promise | void; export type RuleRender = (form: FormInstance) => RuleObject; diff --git a/src/useForm.ts b/src/useForm.ts index 1f90086d..d1f8ea51 100644 --- a/src/useForm.ts +++ b/src/useForm.ts @@ -578,8 +578,11 @@ export class FormStore { }); // Notify dependencies children with parent update + // We need delay to trigger validate in case Field is under render props const childrenFields = this.getDependencyChildrenFields(namePath); - this.validateFields(childrenFields); + if (childrenFields.length) { + this.validateFields(childrenFields); + } this.notifyObservers(prevStore, childrenFields, { type: 'dependenciesUpdate', diff --git a/tests/common/InfoField.tsx b/tests/common/InfoField.tsx index 2acd6f52..54613067 100644 --- a/tests/common/InfoField.tsx +++ b/tests/common/InfoField.tsx @@ -13,17 +13,21 @@ export const Input = ({ value = '', ...props }) => = ({ children, ...props }) => ( - {(control, { errors, validating }) => ( -
- {children ? React.cloneElement(children, control) : } -
    - {errors.map((error, index) => ( -
  • {error}
  • - ))} -
- {validating && } -
- )} + {(control, info) => { + const { errors, validating } = info; + + return ( +
+ {children ? React.cloneElement(children, control) : } +
    + {errors.map((error, index) => ( +
  • {error}
  • + ))} +
+ {validating && } +
+ ); + }}
); diff --git a/tests/common/index.js b/tests/common/index.ts similarity index 96% rename from tests/common/index.js rename to tests/common/index.ts index 88af2608..4d9b3cdd 100644 --- a/tests/common/index.js +++ b/tests/common/index.ts @@ -25,7 +25,7 @@ export function matchError(wrapper, error) { } } -export function getField(wrapper, index = 0) { +export function getField(wrapper, index: string | number = 0) { if (typeof index === 'number') { return wrapper.find(Field).at(index); } diff --git a/tests/dependencies.test.js b/tests/dependencies.test.js index 89ce1459..f7c4eae8 100644 --- a/tests/dependencies.test.js +++ b/tests/dependencies.test.js @@ -167,7 +167,7 @@ describe('Form.Dependencies', () => { const spy = jest.fn(); const wrapper = mount(
- true}> + true}> {() => { spy(); return 'gogogo'; @@ -182,27 +182,20 @@ describe('Form.Dependencies', () => { , ); expect(spy).toHaveBeenCalledTimes(1); - await changeValue(getField(wrapper, 2), 'value2'); + await changeValue(getField(wrapper, 1), 'value1'); // sync start // valueUpdate -> rerender by shouldUpdate // depsUpdate -> rerender by deps // [ react rerender once -> 2 ] // sync end - // async start - // validateFinish -> rerender by shouldUpdate - // [ react rerender once -> 3 ] - // async end - expect(spy).toHaveBeenCalledTimes(3); - await changeValue(getField(wrapper, 1), 'value1'); + expect(spy).toHaveBeenCalledTimes(2); + + await changeValue(getField(wrapper, 2), 'value2'); // sync start // valueUpdate -> rerender by shouldUpdate // depsUpdate -> rerender by deps - // [ react rerender once -> 4 ] + // [ react rerender once -> 3 ] // sync end - // async start - // validateFinish -> rerender by shouldUpdate - // [ react rerender once -> 5 ] - // async end - expect(spy).toHaveBeenCalledTimes(5); + expect(spy).toHaveBeenCalledTimes(3); }); }); diff --git a/tests/legacy/dynamic-binding.test.js b/tests/legacy/dynamic-binding.test.js index 48214c33..b1b74ae1 100644 --- a/tests/legacy/dynamic-binding.test.js +++ b/tests/legacy/dynamic-binding.test.js @@ -2,8 +2,6 @@ import React from 'react'; import { mount } from 'enzyme'; import Form, { Field } from '../../src'; import { Input } from '../common/InfoField'; -import { changeValue, getField } from '../common'; -import timeout from '../common/timeout'; describe('legacy.dynamic-binding', () => { const getInput = (wrapper, id) => wrapper.find(id).last(); diff --git a/tests/validate.test.js b/tests/validate.test.tsx similarity index 88% rename from tests/validate.test.js rename to tests/validate.test.tsx index 4de387f6..c1d2e865 100644 --- a/tests/validate.test.js +++ b/tests/validate.test.tsx @@ -498,7 +498,7 @@ describe('Form.Validate', () => { [ { name: 'serialization', first: true, second: false, validateFirst: true }, - { name: 'parallel', first: true, second: true, validateFirst: 'parallel' }, + { name: 'parallel', first: true, second: true, validateFirst: 'parallel' as const }, ].forEach(({ name, first, second, validateFirst }) => { it(name, async () => { let ruleFirst = false; @@ -532,6 +532,7 @@ describe('Form.Validate', () => { await changeValue(wrapper, 'test'); await timeout(); + wrapper.update(); matchError(wrapper, 'failed first'); expect(ruleFirst).toEqual(first); @@ -610,5 +611,80 @@ describe('Form.Validate', () => { wrapper.find('button').simulate('click'); expect(renderProps.mock.calls[0][1]).toEqual(expect.objectContaining({ validating: true })); }); + + it('renderProps should use latest rules', async () => { + let failedTriggerTimes = 0; + let passedTriggerTimes = 0; + + interface FormStore { + username: string; + password: string; + } + + const Demo = () => ( +
+ + shouldUpdate={(prev, cur) => prev.username !== cur.username}> + {(_, __, { getFieldValue }) => { + const value = getFieldValue('username'); + + if (value === 'removed') { + return null; + } + + return ( + { + failedTriggerTimes += 1; + throw new Error('Failed'); + }, + }, + ] + : [ + { + validator: async () => { + passedTriggerTimes += 1; + }, + }, + ] + } + /> + ); + }} + + + ); + + const wrapper = mount(); + + expect(failedTriggerTimes).toEqual(0); + expect(passedTriggerTimes).toEqual(0); + + // Failed of second input + await changeValue(getField(wrapper, 1), ''); + matchError(getField(wrapper, 2), true); + + expect(failedTriggerTimes).toEqual(1); + expect(passedTriggerTimes).toEqual(0); + + // Changed first to trigger update + await changeValue(getField(wrapper, 0), 'light'); + matchError(getField(wrapper, 2), false); + + expect(failedTriggerTimes).toEqual(1); + expect(passedTriggerTimes).toEqual(1); + + // Remove should not trigger validate + await changeValue(getField(wrapper, 0), 'removed'); + + expect(failedTriggerTimes).toEqual(1); + expect(passedTriggerTimes).toEqual(1); + }); }); /* eslint-enable no-template-curly-in-string */