diff --git a/docs/demo/useWatch-list.md b/docs/demo/useWatch-list.md new file mode 100644 index 00000000..1a14f737 --- /dev/null +++ b/docs/demo/useWatch-list.md @@ -0,0 +1,3 @@ +## useWatch-list + + diff --git a/docs/demo/useWatch.md b/docs/demo/useWatch.md new file mode 100644 index 00000000..04e70536 --- /dev/null +++ b/docs/demo/useWatch.md @@ -0,0 +1,3 @@ +## useWatch + + diff --git a/docs/examples/useWatch-list.tsx b/docs/examples/useWatch-list.tsx new file mode 100644 index 00000000..d6677ce0 --- /dev/null +++ b/docs/examples/useWatch-list.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import Form, { Field } from 'rc-field-form'; +import Input from './components/Input'; + +const { List, useForm } = Form; + +const Demo = () => { + const [form] = useForm(); + const users = Form.useWatch(['users'], form) || []; + + console.log('values', users); + + return ( +
+
+ list length:{users.length} +
+ Users: {JSON.stringify(users, null, 2)} + + + + + {(fields, { add, remove }) => { + return ( +
+ {fields.map((field, index) => ( + + {control => ( +
+ {index + 1} + + remove(index)}>Remove +
+ )} +
+ ))} + +
+ ); + }} +
+
+
+ ); +}; + +export default Demo; diff --git a/docs/examples/useWatch.tsx b/docs/examples/useWatch.tsx new file mode 100644 index 00000000..1f0056e9 --- /dev/null +++ b/docs/examples/useWatch.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import Form, { Field } from 'rc-field-form'; +import Input from './components/Input'; + +let x = 0; + +const Demo = React.memo(() => { + const values = Form.useWatch(['demo']); + console.log('demo watch', values); + return ( + + + + ); +}); +const Demo2 = React.memo(() => { + const values = Form.useWatch(['demo2']); + console.log('demo2 watch', values); + return ( + + + + ); +}); + +export default () => { + const [form] = Form.useForm(); + const [visible, setVisible] = useState(true); + const [visible2, setVisible2] = useState(true); + const [visible3, setVisible3] = useState(true); + const values = Form.useWatch([], form); + console.log('main watch', values); + return ( + <> +
console.log('submit values', v)} + > + no render + + + + name + {visible && ( + + + + )} + age + + + + initialValue + {visible3 && ( + + + + )} + name、age 改变 render + + {() => { + x += 1; + return ` ${x}`; + }} + +
+ demo1 + + demo2 + {visible2 && } + + + + + + + + + + + ); +}; diff --git a/src/FieldContext.ts b/src/FieldContext.ts index 2cacf058..2940e3c4 100644 --- a/src/FieldContext.ts +++ b/src/FieldContext.ts @@ -36,6 +36,7 @@ const Context = React.createContext({ setInitialValues: warningFunc, destroyForm: warningFunc, setCallbacks: warningFunc, + registerWatch: warningFunc, getFields: warningFunc, setValidateMessages: warningFunc, setPreserve: warningFunc, diff --git a/src/index.tsx b/src/index.tsx index 031e68e8..3e24431c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ import FieldForm, { FormProps } from './Form'; import { FormProvider } from './FormContext'; import FieldContext from './FieldContext'; import ListContext from './ListContext'; +import useWatch from './useWatch'; const InternalForm = React.forwardRef(FieldForm) as ( props: FormProps & { ref?: React.Ref> }, @@ -18,6 +19,7 @@ interface RefFormType extends InternalFormType { Field: typeof Field; List: typeof List; useForm: typeof useForm; + useWatch: typeof useWatch; } const RefForm: RefFormType = InternalForm as RefFormType; @@ -26,6 +28,7 @@ RefForm.FormProvider = FormProvider; RefForm.Field = Field; RefForm.List = List; RefForm.useForm = useForm; +RefForm.useWatch = useWatch; export { FormInstance, Field, List, useForm, FormProvider, FormProps, FieldContext, ListContext }; diff --git a/src/interface.ts b/src/interface.ts index d0cb11a3..362eff54 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -193,6 +193,8 @@ export interface Callbacks { onFinishFailed?: (errorInfo: ValidateErrorEntity) => void; } +export type WatchCallBack = (values: Store, namePathList: InternalNamePath[]) => void; + export interface InternalHooks { dispatch: (action: ReducerAction) => void; initEntityValue: (entity: FieldEntity) => void; @@ -201,6 +203,7 @@ export interface InternalHooks { setInitialValues: (values: Store, init: boolean) => void; destroyForm: () => void; setCallbacks: (callbacks: Callbacks) => void; + registerWatch: (callback: WatchCallBack) => () => void; getFields: (namePathList?: InternalNamePath[]) => FieldData[]; setValidateMessages: (validateMessages: ValidateMessages) => void; setPreserve: (preserve?: boolean) => void; @@ -255,6 +258,9 @@ export type InternalFormInstance = Omit & { * We pass the `HOOK_MARK` as key to avoid user call the function. */ getInternalHooks: (secret: string) => InternalHooks | null; + + /** @private Internal usage. Do not use it in your production */ + _init?: boolean; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/useForm.ts b/src/useForm.ts index 025a62b8..45e3e89c 100644 --- a/src/useForm.ts +++ b/src/useForm.ts @@ -21,6 +21,7 @@ import type { InternalFieldData, ValuedNotifyInfo, RuleError, + WatchCallBack, } from './interface'; import { HOOK_MARK } from './FieldContext'; import { allPromiseFinish } from './utils/asyncUtil'; @@ -93,6 +94,7 @@ export class FormStore { setFieldsValue: this.setFieldsValue, validateFields: this.validateFields, submit: this.submit, + _init: true, getInternalHooks: this.getInternalHooks, }); @@ -114,6 +116,7 @@ export class FormStore { getFields: this.getFields, setPreserve: this.setPreserve, getInitialValue: this.getInitialValue, + registerWatch: this.registerWatch, }; } @@ -141,6 +144,7 @@ export class FormStore { // We will take consider prev form unmount fields. // When the field is not `preserve`, we need fill this with initialValues instead of store. + // eslint-disable-next-line array-callback-return this.prevWithoutPreserves?.map(({ key: namePath }) => { nextStore = setValue(nextStore, namePath, getValue(initialValues, namePath)); }); @@ -180,6 +184,28 @@ export class FormStore { this.preserve = preserve; }; + // ============================= Watch ============================ + private watchList: WatchCallBack[] = []; + + private registerWatch: InternalHooks['registerWatch'] = callback => { + this.watchList.push(callback); + + return () => { + this.watchList = this.watchList.filter(fn => fn !== callback); + }; + }; + + private notifyWatch = (namePath: InternalNamePath[] = []) => { + // No need to cost perf when nothing need to watch + if (this.watchList.length) { + const values = this.getFieldsValue(); + + this.watchList.forEach(callback => { + callback(values, namePath); + }); + } + }; + // ========================== Dev Warning ========================= private timeoutId: any = null; @@ -498,6 +524,7 @@ export class FormStore { this.updateStore(setValues({}, this.initialValues)); this.resetWithFieldInitialValue(); this.notifyObservers(prevStore, null, { type: 'reset' }); + this.notifyWatch(); return; } @@ -509,6 +536,7 @@ export class FormStore { }); this.resetWithFieldInitialValue({ namePathList }); this.notifyObservers(prevStore, namePathList, { type: 'reset' }); + this.notifyWatch(namePathList); }; private setFields = (fields: FieldData[]) => { @@ -516,9 +544,12 @@ export class FormStore { const prevStore = this.store; + const namePathList: InternalNamePath[] = []; + fields.forEach((fieldData: FieldData) => { const { name, errors, ...data } = fieldData; const namePath = getNamePath(name); + namePathList.push(namePath); // Value if ('value' in data) { @@ -530,6 +561,8 @@ export class FormStore { data: fieldData, }); }); + + this.notifyWatch(namePathList); }; private getFields = (): InternalFieldData[] => { @@ -573,6 +606,8 @@ export class FormStore { private registerField = (entity: FieldEntity) => { this.fieldEntities.push(entity); + const namePath = entity.getNamePath(); + this.notifyWatch([namePath]); // Set initial values if (entity.props.initialValue !== undefined) { @@ -591,8 +626,6 @@ export class FormStore { const mergedPreserve = preserve !== undefined ? preserve : this.preserve; if (mergedPreserve === false && (!isListField || subNamePath.length > 1)) { - const namePath = entity.getNamePath(); - const defaultValue = isListField ? undefined : this.getInitialValue(namePath); if ( @@ -614,6 +647,8 @@ export class FormStore { this.triggerDependenciesUpdate(prevStore, namePath); } } + + this.notifyWatch([namePath]); }; }; @@ -679,6 +714,7 @@ export class FormStore { type: 'valueUpdate', source: 'internal', }); + this.notifyWatch([namePath]); // Dependencies update const childrenFields = this.triggerDependenciesUpdate(prevStore, namePath); @@ -701,13 +737,15 @@ export class FormStore { const prevStore = this.store; if (store) { - this.updateStore(setValues(this.store, store)); + const nextStore = setValues(this.store, store); + this.updateStore(nextStore); } this.notifyObservers(prevStore, null, { type: 'valueUpdate', source: 'external', }); + this.notifyWatch(); }; private getDependencyChildrenFields = (rootNamePath: InternalNamePath): InternalNamePath[] => { diff --git a/src/useWatch.ts b/src/useWatch.ts new file mode 100644 index 00000000..2d39c39e --- /dev/null +++ b/src/useWatch.ts @@ -0,0 +1,58 @@ +import type { FormInstance } from '.'; +import { FieldContext } from '.'; +import warning from 'rc-util/lib/warning'; +import { HOOK_MARK } from './FieldContext'; +import type { InternalFormInstance, NamePath, Store } from './interface'; +import { useState, useContext, useEffect, useRef } from 'react'; +import { getNamePath, getValue } from './utils/valueUtil'; + +const useWatch = (dependencies: NamePath = [], form?: FormInstance) => { + const [value, setValue] = useState(); + + const fieldContext = useContext(FieldContext); + const formInstance = (form as InternalFormInstance) || fieldContext; + const isValidForm = formInstance && formInstance._init; + + // Warning if not exist form instance + if (process.env.NODE_ENV !== 'production') { + warning( + isValidForm, + 'useWatch requires a form instance since it can not auto detect from context.', + ); + } + + const namePath = getNamePath(dependencies); + const namePathRef = useRef(namePath); + namePathRef.current = namePath; + + useEffect( + () => { + // Skip if not exist form instance + if (!isValidForm) { + return; + } + + const { getFieldsValue, getInternalHooks } = formInstance; + const { registerWatch } = getInternalHooks(HOOK_MARK); + + const cancelRegister = registerWatch(store => { + const newValue = getValue(store, namePathRef.current); + setValue(newValue); + }); + + // TODO: We can improve this perf in future + const initialValue = getValue(getFieldsValue(), namePathRef.current); + setValue(initialValue); + + return cancelRegister; + }, + /* eslint-disable react-hooks/exhaustive-deps */ + // We do not need re-register since namePath content is the same + [], + /* eslint-enable */ + ); + + return value; +}; + +export default useWatch; diff --git a/tests/useWatch.test.tsx b/tests/useWatch.test.tsx new file mode 100644 index 00000000..7a1809eb --- /dev/null +++ b/tests/useWatch.test.tsx @@ -0,0 +1,222 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import type { FormInstance } from '../src'; +import { List } from '../src'; +import Form, { Field } from '../src'; +import timeout from './common/timeout'; +import { act } from 'react-dom/test-utils'; +import { Input } from './common/InfoField'; + +describe('useWatch', () => { + let staticForm: FormInstance; + + it('field initialValue', async () => { + const Demo = () => { + const [form] = Form.useForm(); + const nameValue = Form.useWatch('name', form); + + return ( +
+
+ + + +
+
{nameValue}
+
+ ); + }; + await act(async () => { + const wrapper = mount(); + await timeout(); + expect(wrapper.find('.values').text()).toEqual('bamboo'); + }); + }); + + it('form initialValue', async () => { + const Demo = () => { + const [form] = Form.useForm(); + const nameValue = Form.useWatch(['name'], form); + + return ( +
+
+ + + +
+
{nameValue}
+
+ ); + }; + await act(async () => { + const wrapper = mount(); + await timeout(); + expect(wrapper.find('.values').text()).toEqual('bamboo'); + }); + }); + + it('change value with form api', async () => { + const Demo = () => { + const [form] = Form.useForm(); + const nameValue = Form.useWatch(['name'], form); + + return ( +
+
{ + staticForm = instance; + }} + > + + + +
+
{nameValue}
+
+ ); + }; + await act(async () => { + const wrapper = mount(); + await timeout(); + staticForm.setFields([{ name: 'name', value: 'little' }]); + expect(wrapper.find('.values').text()).toEqual('little'); + + staticForm.setFieldsValue({ name: 'light' }); + expect(wrapper.find('.values').text()).toEqual('light'); + + staticForm.resetFields(); + expect(wrapper.find('.values').text()).toEqual(''); + }); + }); + + describe('unmount', () => { + it('basic', async () => { + const Demo = ({ visible }: { visible: boolean }) => { + const [form] = Form.useForm(); + const nameValue = Form.useWatch(['name'], form); + + return ( +
+
+ {visible && ( + + + + )} +
+
{nameValue}
+
+ ); + }; + + await act(async () => { + const wrapper = mount(); + await timeout(); + + expect(wrapper.find('.values').text()).toEqual('bamboo'); + + wrapper.setProps({ visible: false }); + expect(wrapper.find('.values').text()).toEqual(''); + + wrapper.setProps({ visible: true }); + expect(wrapper.find('.values').text()).toEqual('bamboo'); + }); + }); + + it('nest children component', async () => { + const DemoWatch = () => { + Form.useWatch(['name']); + + return ( + + + + ); + }; + + const Demo = ({ visible }: { visible: boolean }) => { + const [form] = Form.useForm(); + const nameValue = Form.useWatch(['name'], form); + + return ( +
+
+ {visible && } + +
{nameValue}
+
+ ); + }; + + await act(async () => { + const wrapper = mount(); + await timeout(); + + expect(wrapper.find('.values').text()).toEqual('bamboo'); + + wrapper.setProps({ visible: false }); + expect(wrapper.find('.values').text()).toEqual(''); + + wrapper.setProps({ visible: true }); + expect(wrapper.find('.values').text()).toEqual('bamboo'); + }); + }); + }); + + it('list', async () => { + const Demo = () => { + const [form] = Form.useForm(); + const users = Form.useWatch(['users'], form) || []; + + return ( +
+
{JSON.stringify(users)}
+ + {(fields, { remove }) => { + return ( +
+ {fields.map((field, index) => ( + + {control => ( + + )} + + ))} +
+ ); + }} +
+ + ); + }; + await act(async () => { + const wrapper = mount(); + await timeout(); + expect(wrapper.find('.values').text()).toEqual(JSON.stringify(['bamboo', 'light'])); + + wrapper.find('.remove').at(0).simulate('click'); + await timeout(); + expect(wrapper.find('.values').text()).toEqual(JSON.stringify(['light'])); + }); + }); + + it('warning if not provide form', () => { + const errorSpy = jest.spyOn(console, 'error'); + + const Demo = () => { + Form.useWatch([]); + return null; + }; + + mount(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: useWatch requires a form instance since it can not auto detect from context.', + ); + }); +});