From 982293e19f526efdbe26b0635a80a15c8e7ebfaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Wed, 17 Dec 2025 17:25:10 +0800 Subject: [PATCH 1/9] fix(form): prevent useWatch from triggering twice during Form.List updates --- src/useForm.ts | 2 +- tests/useWatch.test.tsx | 65 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/useForm.ts b/src/useForm.ts index e2cd4b70..0b6e464c 100644 --- a/src/useForm.ts +++ b/src/useForm.ts @@ -775,7 +775,7 @@ export class FormStore { type: 'valueUpdate', source: 'internal', }); - this.notifyWatch([namePath]); + this.batchNotifyWatch(namePath); // Dependencies update const childrenFields = this.triggerDependenciesUpdate(prevStore, namePath); diff --git a/tests/useWatch.test.tsx b/tests/useWatch.test.tsx index b26dfcf1..1d18e03b 100644 --- a/tests/useWatch.test.tsx +++ b/tests/useWatch.test.tsx @@ -536,4 +536,69 @@ describe('useWatch', () => { expect(list[0]).toEqual({}); expect(list[1]).toEqual({ name: 'bamboo' }); }); + + it('list remove should not trigger intermediate undefined value', async () => { + const snapshots: any[] = []; + + const Demo: React.FC = () => { + const [form] = Form.useForm(); + const users = Form.useWatch(['users'], form) || []; + + React.useEffect(() => { + snapshots.push(users); + }, [users]); + + return ( +
+
{JSON.stringify(users)}
+ + {(fields, { remove }) => ( +
+ {fields.map((field, index) => ( + + {control => ( + + )} + + ))} +
+ )} +
+ + ); + }; + + const { container } = render(); + + await act(async () => { + await timeout(); + }); + + // Initial + expect(container.querySelector('.values')?.textContent).toEqual( + JSON.stringify(['bamboo', 'light']), + ); + + // Remove index 1 + fireEvent.click(container.querySelector('.remove')!); + + await act(async () => { + await timeout(); + }); + + // Final + expect(container.querySelector('.values')?.textContent).toEqual( + JSON.stringify(['bamboo']), + ); + + // Should not have intermediate state like ['bamboo', undefined] + expect( + snapshots.some( + v => Array.isArray(v) && v.length === 2 && v[0] === 'bamboo' && v[1] === undefined, + ), + ).toBe(false); + }); }); From fcc8add87418dd4a2dcf5edd2fa5739872636860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 19 Dec 2025 11:25:04 +0800 Subject: [PATCH 2/9] refactor: move hooks to dedicated directory and update imports [AI] --- src/Form.tsx | 2 +- src/{ => hooks}/useForm.ts | 14 +++++++------- src/{ => hooks}/useWatch.ts | 8 ++++---- src/index.tsx | 4 ++-- src/utils/asyncUtil.ts | 9 +++++++++ tests/useWatch.test.tsx | 2 +- 6 files changed, 24 insertions(+), 15 deletions(-) rename src/{ => hooks}/useForm.ts (99%) rename src/{ => hooks}/useWatch.ts (96%) diff --git a/src/Form.tsx b/src/Form.tsx index 184cc2d5..6804c400 100644 --- a/src/Form.tsx +++ b/src/Form.tsx @@ -8,7 +8,7 @@ import type { InternalFormInstance, FormRef, } from './interface'; -import useForm from './useForm'; +import useForm from './hooks/useForm'; import FieldContext, { HOOK_MARK } from './FieldContext'; import type { FormContextProps } from './FormContext'; import FormContext from './FormContext'; diff --git a/src/useForm.ts b/src/hooks/useForm.ts similarity index 99% rename from src/useForm.ts rename to src/hooks/useForm.ts index 0b6e464c..8645d964 100644 --- a/src/useForm.ts +++ b/src/hooks/useForm.ts @@ -2,7 +2,7 @@ import { merge } from '@rc-component/util/lib/utils/set'; import { mergeWith } from '@rc-component/util'; import warning from '@rc-component/util/lib/warning'; import * as React from 'react'; -import { HOOK_MARK } from './FieldContext'; +import { HOOK_MARK } from '../FieldContext'; import type { Callbacks, FieldData, @@ -27,10 +27,10 @@ import type { ValidateMessages, ValuedNotifyInfo, WatchCallBack, -} from './interface'; -import { allPromiseFinish } from './utils/asyncUtil'; -import { defaultValidateMessages } from './utils/messages'; -import NameMap from './utils/NameMap'; +} from '../interface'; +import { allPromiseFinish } from '../utils/asyncUtil'; +import { defaultValidateMessages } from '../utils/messages'; +import NameMap from '../utils/NameMap'; import { cloneByNamePathList, containsNamePath, @@ -38,8 +38,8 @@ import { getValue, matchNamePath, setValue, -} from './utils/valueUtil'; -import type { BatchTask } from './BatchUpdate'; +} from '../utils/valueUtil'; +import type { BatchTask } from '../BatchUpdate'; type FlexibleFieldEntity = Partial; diff --git a/src/useWatch.ts b/src/hooks/useWatch.ts similarity index 96% rename from src/useWatch.ts rename to src/hooks/useWatch.ts index 74b10176..c656ef53 100644 --- a/src/useWatch.ts +++ b/src/hooks/useWatch.ts @@ -1,15 +1,15 @@ import warning from '@rc-component/util/lib/warning'; import { useContext, useEffect, useMemo, useRef, useState } from 'react'; -import FieldContext, { HOOK_MARK } from './FieldContext'; +import FieldContext, { HOOK_MARK } from '../FieldContext'; import type { FormInstance, InternalFormInstance, NamePath, Store, WatchOptions, -} from './interface'; -import { isFormInstance } from './utils/typeUtil'; -import { getNamePath, getValue } from './utils/valueUtil'; +} from '../interface'; +import { isFormInstance } from '../utils/typeUtil'; +import { getNamePath, getValue } from '../utils/valueUtil'; import { useEvent } from '@rc-component/util'; type ReturnPromise = T extends Promise ? ValueType : never; diff --git a/src/index.tsx b/src/index.tsx index bf535b72..4433aa77 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,13 +2,13 @@ import * as React from 'react'; import type { FormRef, FormInstance } from './interface'; import Field from './Field'; import List from './List'; -import useForm from './useForm'; +import useForm from './hooks/useForm'; import type { FormProps } from './Form'; import FieldForm from './Form'; import { FormProvider } from './FormContext'; import FieldContext from './FieldContext'; import ListContext from './ListContext'; -import useWatch from './useWatch'; +import useWatch from './hooks/useWatch'; const InternalForm = React.forwardRef(FieldForm) as ( props: FormProps & { ref?: React.Ref> }, diff --git a/src/utils/asyncUtil.ts b/src/utils/asyncUtil.ts index 51593b5f..a3b7e542 100644 --- a/src/utils/asyncUtil.ts +++ b/src/utils/asyncUtil.ts @@ -32,3 +32,12 @@ export function allPromiseFinish(promiseList: Promise[]): Promise { + const channel = new MessageChannel(); + channel.port1.onmessage = fn; + channel.port2.postMessage(null); +}; diff --git a/tests/useWatch.test.tsx b/tests/useWatch.test.tsx index 1d18e03b..f195a8d5 100644 --- a/tests/useWatch.test.tsx +++ b/tests/useWatch.test.tsx @@ -5,7 +5,7 @@ import { List } from '../src'; import Form, { Field } from '../src'; import timeout from './common/timeout'; import { Input } from './common/InfoField'; -import { stringify } from '../src/useWatch'; +import { stringify } from '../src/hooks/useWatch'; import { changeValue } from './common'; describe('useWatch', () => { From 97a1919f1979e264e01fdfebe2b5de37d34dfbc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 19 Dec 2025 14:25:59 +0800 Subject: [PATCH 3/9] chore: batcher --- src/BatchUpdate.tsx | 34 ------------------ src/FieldContext.ts | 1 - src/Form.tsx | 39 -------------------- src/hooks/useForm.ts | 71 +++++++++++++++---------------------- src/hooks/useNotifyWatch.ts | 62 ++++++++++++++++++++++++++++++++ src/interface.ts | 1 - src/utils/asyncUtil.ts | 9 ----- src/utils/valueUtil.ts | 2 +- 8 files changed, 92 insertions(+), 127 deletions(-) delete mode 100644 src/BatchUpdate.tsx create mode 100644 src/hooks/useNotifyWatch.ts diff --git a/src/BatchUpdate.tsx b/src/BatchUpdate.tsx deleted file mode 100644 index 9482dd04..00000000 --- a/src/BatchUpdate.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; - -export type BatchTask = (key: string, callback: VoidFunction) => void; - -export interface BatchUpdateRef { - batch: BatchTask; -} - -const BatchUpdate = React.forwardRef((_, ref) => { - const [batchInfo, setBatchInfo] = React.useState>({}); - - React.useLayoutEffect(() => { - const keys = Object.keys(batchInfo); - if (keys.length) { - keys.forEach(key => { - batchInfo[key]?.(); - }); - setBatchInfo({}); - } - }, [batchInfo]); - - React.useImperativeHandle(ref, () => ({ - batch: (key, callback) => { - setBatchInfo(ori => ({ - ...ori, - [key]: callback, - })); - }, - })); - - return null; -}); - -export default BatchUpdate; diff --git a/src/FieldContext.ts b/src/FieldContext.ts index 1f927949..d2b6f4d5 100644 --- a/src/FieldContext.ts +++ b/src/FieldContext.ts @@ -42,7 +42,6 @@ const Context = React.createContext({ setValidateMessages: warningFunc, setPreserve: warningFunc, getInitialValue: warningFunc, - setBatchUpdate: warningFunc, }; }, }); diff --git a/src/Form.tsx b/src/Form.tsx index 6804c400..84a4f62d 100644 --- a/src/Form.tsx +++ b/src/Form.tsx @@ -14,8 +14,6 @@ import type { FormContextProps } from './FormContext'; import FormContext from './FormContext'; import { isSimilar } from './utils/valueUtil'; import ListContext from './ListContext'; -import type { BatchTask, BatchUpdateRef } from './BatchUpdate'; -import BatchUpdate from './BatchUpdate'; type BaseFormProps = Omit, 'onSubmit' | 'children'>; @@ -72,7 +70,6 @@ const Form: React.ForwardRefRenderFunction = ( setValidateMessages, setPreserve, destroyForm, - setBatchUpdate, } = (formInstance as InternalFormInstance).getInternalHooks(HOOK_MARK); // Pass ref with form instance @@ -121,41 +118,6 @@ const Form: React.ForwardRefRenderFunction = ( mountRef.current = true; } - // ======================== Batch Update ======================== - // zombieJ: - // To avoid Form self re-render, - // We create a sub component `BatchUpdate` to handle batch update logic. - // When the call with do not change immediate, we will batch the update - // and flush it in `useLayoutEffect` for next tick. - - // Set batch update ref - const batchUpdateRef = React.useRef(null); - const batchUpdateTasksRef = React.useRef<[key: string, fn: VoidFunction][]>([]); - - const tryFlushBatch = () => { - if (batchUpdateRef.current) { - batchUpdateTasksRef.current.forEach(([key, fn]) => { - batchUpdateRef.current.batch(key, fn); - }); - batchUpdateTasksRef.current = []; - } - }; - - // Ref update - const setBatchUpdateRef = React.useCallback((batchUpdate: BatchUpdateRef | null) => { - batchUpdateRef.current = batchUpdate; - tryFlushBatch(); - }, []); - - // Task list - - const batchUpdate: BatchTask = (key, callback) => { - batchUpdateTasksRef.current.push([key, callback]); - tryFlushBatch(); - }; - - setBatchUpdate(batchUpdate); - // ========================== Unmount =========================== React.useEffect( () => () => destroyForm(clearOnDestroy), @@ -197,7 +159,6 @@ const Form: React.ForwardRefRenderFunction = ( const wrapperNode = ( {childrenNode} - ); diff --git a/src/hooks/useForm.ts b/src/hooks/useForm.ts index 8645d964..b771581d 100644 --- a/src/hooks/useForm.ts +++ b/src/hooks/useForm.ts @@ -26,7 +26,6 @@ import type { ValidateErrorEntity, ValidateMessages, ValuedNotifyInfo, - WatchCallBack, } from '../interface'; import { allPromiseFinish } from '../utils/asyncUtil'; import { defaultValidateMessages } from '../utils/messages'; @@ -39,7 +38,7 @@ import { matchNamePath, setValue, } from '../utils/valueUtil'; -import type { BatchTask } from '../BatchUpdate'; +import WatcherCenter from './useNotifyWatch'; type FlexibleFieldEntity = Partial; @@ -78,6 +77,8 @@ export class FormStore { private lastValidatePromise: Promise = null; + private watcherCenter = new WatcherCenter(this); + constructor(forceRootUpdate: () => void) { this.forceRootUpdate = forceRootUpdate; } @@ -121,7 +122,6 @@ export class FormStore { setPreserve: this.setPreserve, getInitialValue: this.getInitialValue, registerWatch: this.registerWatch, - setBatchUpdate: this.setBatchUpdate, }; } @@ -195,48 +195,31 @@ export class FormStore { }; // ============================= Watch ============================ - private watchList: WatchCallBack[] = []; - private registerWatch: InternalHooks['registerWatch'] = callback => { - this.watchList.push(callback); - - return () => { - this.watchList = this.watchList.filter(fn => fn !== callback); - }; + return this.watcherCenter.register(callback); }; private notifyWatch = (namePath: InternalNamePath[] = []) => { - // No need to cost perf when nothing need to watch - if (this.watchList.length) { - const values = this.getFieldsValue(); - const allValues = this.getFieldsValue(true); - - this.watchList.forEach(callback => { - callback(values, allValues, namePath); - }); - } + // // No need to cost perf when nothing need to watch + // if (this.watchList.length) { + // const values = this.getFieldsValue(); + // const allValues = this.getFieldsValue(true); + + // this.watchList.forEach(callback => { + // callback(values, allValues, namePath); + // }); + // } + this.watcherCenter.notify(namePath); }; - private notifyWatchNamePathList: InternalNamePath[] = []; - private batchNotifyWatch = (namePath: InternalNamePath) => { - this.notifyWatchNamePathList.push(namePath); - this.batch('notifyWatch', () => { - this.notifyWatch(this.notifyWatchNamePathList); - this.notifyWatchNamePathList = []; - }); - }; - - // ============================= Batch ============================ - private batchUpdate: BatchTask; - - private setBatchUpdate = (batchUpdate: BatchTask) => { - this.batchUpdate = batchUpdate; - }; - - // Batch call the task, only last will be called - private batch = (key: string, callback: VoidFunction) => { - this.batchUpdate(key, callback); - }; + // private notifyWatchNamePathList: InternalNamePath[] = []; + // private batchNotifyWatch = (namePath: InternalNamePath) => { + // this.notifyWatchNamePathList.push(namePath); + // this.batch('notifyWatch', () => { + // this.notifyWatch(this.notifyWatchNamePathList); + // this.notifyWatchNamePathList = []; + // }); + // }; // ========================== Dev Warning ========================= private timeoutId: any = null; @@ -669,7 +652,8 @@ export class FormStore { private registerField = (entity: FieldEntity) => { this.fieldEntities.push(entity); const namePath = entity.getNamePath(); - this.batchNotifyWatch(namePath); + // this.batchNotifyWatch(namePath); + this.notifyWatch([namePath]); // Set initial values if (entity.props.initialValue !== undefined) { @@ -709,7 +693,8 @@ export class FormStore { } } - this.batchNotifyWatch(namePath); + // this.batchNotifyWatch(namePath); + this.notifyWatch([namePath]); }; }; @@ -775,7 +760,8 @@ export class FormStore { type: 'valueUpdate', source: 'internal', }); - this.batchNotifyWatch(namePath); + // this.batchNotifyWatch(namePath); + this.notifyWatch([namePath]); // Dependencies update const childrenFields = this.triggerDependenciesUpdate(prevStore, namePath); @@ -1078,6 +1064,7 @@ function useForm(form?: FormInstance): [FormInstance(null); const [, forceUpdate] = React.useState({}); + // Create singleton FormStore if (!formRef.current) { if (form) { formRef.current = form; diff --git a/src/hooks/useNotifyWatch.ts b/src/hooks/useNotifyWatch.ts new file mode 100644 index 00000000..5beea99e --- /dev/null +++ b/src/hooks/useNotifyWatch.ts @@ -0,0 +1,62 @@ +import { matchNamePath } from '../utils/valueUtil'; +import type { InternalNamePath, WatchCallBack } from '../interface'; +import type { FormStore } from './useForm'; + +/** + * Call action with delay in macro task. + */ +const macroTask = (fn: VoidFunction) => { + const channel = new MessageChannel(); + channel.port1.onmessage = fn; + channel.port2.postMessage(null); +}; + +export default class WatcherCenter { + namePathList: InternalNamePath[] = []; + taskId: number = 0; + + watcherList = new Set(); + form: FormStore; + + constructor(form: FormStore) { + this.form = form; + } + + public register(callback: WatchCallBack): VoidFunction { + this.watcherList.add(callback); + + return () => { + this.watcherList.delete(callback); + }; + } + + public notify(namePath: InternalNamePath[]) { + // Insert with deduplication + namePath.forEach(path => { + if (this.namePathList.every(exist => !matchNamePath(exist, path))) { + this.namePathList.push(path); + } + }); + + this.doBatch(); + } + + private doBatch() { + this.taskId += 1; + const currentId = this.taskId; + + macroTask(() => { + if (currentId === this.taskId && this.watcherList.size) { + const formInst = this.form.getForm(); + const values = formInst.getFieldsValue(); + const allValues = formInst.getFieldsValue(true); + + this.watcherList.forEach(callback => { + callback(values, allValues, this.namePathList); + }); + + this.namePathList = []; + } + }); + } +} diff --git a/src/interface.ts b/src/interface.ts index b8f30b73..926093b0 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -240,7 +240,6 @@ export interface InternalHooks { setValidateMessages: (validateMessages: ValidateMessages) => void; setPreserve: (preserve?: boolean) => void; getInitialValue: (namePath: InternalNamePath) => StoreValue; - setBatchUpdate: (fn: BatchTask) => void; } /** Only return partial when type is not any */ diff --git a/src/utils/asyncUtil.ts b/src/utils/asyncUtil.ts index a3b7e542..51593b5f 100644 --- a/src/utils/asyncUtil.ts +++ b/src/utils/asyncUtil.ts @@ -32,12 +32,3 @@ export function allPromiseFinish(promiseList: Promise[]): Promise { - const channel = new MessageChannel(); - channel.port1.onmessage = fn; - channel.port2.postMessage(null); -}; diff --git a/src/utils/valueUtil.ts b/src/utils/valueUtil.ts index 21c123d4..9f4e6426 100644 --- a/src/utils/valueUtil.ts +++ b/src/utils/valueUtil.ts @@ -48,7 +48,7 @@ export function containsNamePath( * Check if `namePath` is super set or equal of `subNamePath`. * @param namePath A list of `InternalNamePath[]` * @param subNamePath Compare `InternalNamePath` - * @param partialMatch True will make `[a, b]` match `[a, b, c]` + * @param partialMatch Default false. True will make `[a, b]` match `[a, b, c]` */ export function matchNamePath( namePath: InternalNamePath, From 5eef5928c7070c53cdadf2fb89e59a4921a55bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 19 Dec 2025 14:29:43 +0800 Subject: [PATCH 4/9] chore: of it --- jest.config.ts | 1 + tests/setup.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/setup.ts diff --git a/jest.config.ts b/jest.config.ts index 84f2eee7..a07649f3 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,3 +1,4 @@ export default { + setupFiles: ['/tests/setup.ts'], setupFilesAfterEnv: ['/tests/setupAfterEnv.ts'], }; diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 00000000..f6b5042e --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,28 @@ +window.MessageChannel = class { + port1: any; + port2: any; + + constructor() { + const createPort = () => { + const port = { + onmessage: null, + postMessage: (message: any) => { + setTimeout(() => { + if (port._target && typeof port._target.onmessage === 'function') { + port._target.onmessage({ data: message }); + } + }, 10); + }, + _target: null, + }; + return port; + }; + + const port1 = createPort(); + const port2 = createPort(); + port1._target = port2; + port2._target = port1; + this.port1 = port1; + this.port2 = port2; + } +} as any; From 9c4bc63a742966b9172d257ce5daefc5c7c8ae4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 19 Dec 2025 14:52:59 +0800 Subject: [PATCH 5/9] chore: update config --- docs/examples/debug.tsx | 64 +++++++++++++++++++++++++++++------------ jest.config.ts | 1 - package.json | 2 +- tests/setup.ts | 28 ------------------ tests/setupAfterEnv.ts | 29 +++++++++++++++++++ 5 files changed, 75 insertions(+), 49 deletions(-) delete mode 100644 tests/setup.ts diff --git a/docs/examples/debug.tsx b/docs/examples/debug.tsx index 4a957f4c..a52e6fb2 100644 --- a/docs/examples/debug.tsx +++ b/docs/examples/debug.tsx @@ -4,27 +4,53 @@ import Input from './components/Input'; export default function App() { const [form] = Form.useForm(); - const [keyName, setKeyName] = React.useState(true); + const names = Form.useWatch('names', form); - // const val = Form.useWatch(keyName ? 'name' : 'age', form); - const val = Form.useWatch(values => values[keyName ? 'name' : 'age'], form); + console.log('[Antd V6] names:', names); return ( -
- - - - - - - - {val} -
+
+

Antd V6 - useWatch + Form.List

+ +
+ + {(fields, { add, remove }) => { + return ( + <> + {fields.map(({key, ...field}, index) => ( +
+ + + + +
+ ))} + +
+ + +
+ + ); + }} +
+
+
); } diff --git a/jest.config.ts b/jest.config.ts index a07649f3..84f2eee7 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,4 +1,3 @@ export default { - setupFiles: ['/tests/setup.ts'], setupFilesAfterEnv: ['/tests/setupAfterEnv.ts'], }; diff --git a/package.json b/package.json index 6837d60d..bc50f56c 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "gh-pages": "^6.1.0", "jest": "^29.0.0", "prettier": "^3.1.0", - "rc-test": "^7.0.15", + "rc-test": "^7.1.3", "react": "^18.0.0", "react-dnd": "^8.0.3", "react-dnd-html5-backend": "^8.0.3", diff --git a/tests/setup.ts b/tests/setup.ts deleted file mode 100644 index f6b5042e..00000000 --- a/tests/setup.ts +++ /dev/null @@ -1,28 +0,0 @@ -window.MessageChannel = class { - port1: any; - port2: any; - - constructor() { - const createPort = () => { - const port = { - onmessage: null, - postMessage: (message: any) => { - setTimeout(() => { - if (port._target && typeof port._target.onmessage === 'function') { - port._target.onmessage({ data: message }); - } - }, 10); - }, - _target: null, - }; - return port; - }; - - const port1 = createPort(); - const port2 = createPort(); - port1._target = port2; - port2._target = port1; - this.port1 = port1; - this.port2 = port2; - } -} as any; diff --git a/tests/setupAfterEnv.ts b/tests/setupAfterEnv.ts index 7b0828bf..58503f88 100644 --- a/tests/setupAfterEnv.ts +++ b/tests/setupAfterEnv.ts @@ -1 +1,30 @@ import '@testing-library/jest-dom'; + +window.MessageChannel = class { + port1: any; + port2: any; + + constructor() { + const createPort = () => { + const port = { + onmessage: null, + postMessage: (message: any) => { + setTimeout(() => { + if (port._target && typeof port._target.onmessage === 'function') { + port._target.onmessage({ data: message }); + } + }, 10); + }, + _target: null, + }; + return port; + }; + + const port1 = createPort(); + const port2 = createPort(); + port1._target = port2; + port2._target = port1; + this.port1 = port1; + this.port2 = port2; + } +} as any; From 4028ce36f2ca15987a962d141e24e6646d5ec73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 19 Dec 2025 15:09:43 +0800 Subject: [PATCH 6/9] test: fix test case --- tests/common/index.ts | 31 ++++++++++++--- tests/useWatch.test.tsx | 88 ++++++++++++++++++----------------------- 2 files changed, 65 insertions(+), 54 deletions(-) diff --git a/tests/common/index.ts b/tests/common/index.ts index 23f03605..a9e04015 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -24,18 +24,35 @@ export function getInput( return ele!; } +const nativeSetTimeout = window.setTimeout; + +export async function waitFakeTimer() { + for (let i = 0; i < 10; i += 1) { + await act(async () => { + jest.advanceTimersByTime(1000); + await Promise.resolve(); + }); + } +} + export async function changeValue(wrapper: HTMLElement, value: string | string[]) { const values = Array.isArray(value) ? value : [value]; + const isMockTimer = nativeSetTimeout !== window.setTimeout; + for (let i = 0; i < values.length; i += 1) { fireEvent.change(wrapper, { target: { value: values[i] } }); - await act(async () => { - await timeout(); - }); + if (isMockTimer) { + act(() => { + jest.advanceTimersByTime(1000); + }); + } else { + await act(async () => { + await timeout(); + }); + } } - - return; } export function matchError( @@ -96,3 +113,7 @@ export async function validateFields(form, ...args) { await form.validateFields(...args); }); } + +export function executeMicroTasks() { + jest.advanceTimersByTime(1000); +} diff --git a/tests/useWatch.test.tsx b/tests/useWatch.test.tsx index f195a8d5..3a9a8a39 100644 --- a/tests/useWatch.test.tsx +++ b/tests/useWatch.test.tsx @@ -6,9 +6,17 @@ import Form, { Field } from '../src'; import timeout from './common/timeout'; import { Input } from './common/InfoField'; import { stringify } from '../src/hooks/useWatch'; -import { changeValue } from './common'; +import { changeValue, waitFakeTimer } from './common'; describe('useWatch', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('field initialValue', async () => { const Demo: React.FC = () => { const [form] = Form.useForm(); @@ -26,9 +34,7 @@ describe('useWatch', () => { }; const { container } = render(); - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); @@ -49,9 +55,7 @@ describe('useWatch', () => { }; const { container } = render(); - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); @@ -73,23 +77,24 @@ describe('useWatch', () => { }; const { container } = render(); - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); await act(async () => { staticForm.current?.setFields([{ name: 'name', value: 'little' }]); }); + await waitFakeTimer(); expect(container.querySelector('.values').textContent)?.toEqual('little'); await act(async () => { staticForm.current?.setFieldsValue({ name: 'light' }); }); + await waitFakeTimer(); expect(container.querySelector('.values').textContent)?.toEqual('light'); await act(async () => { staticForm.current?.resetFields(); }); + await waitFakeTimer(); expect(container.querySelector('.values').textContent)?.toEqual(''); }); @@ -113,17 +118,16 @@ describe('useWatch', () => { }; const { container, rerender } = render(); - - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); rerender(); + await waitFakeTimer(); expect(container.querySelector('.values')?.textContent).toEqual(''); rerender(); + await waitFakeTimer(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); @@ -152,15 +156,15 @@ describe('useWatch', () => { }; const { container, rerender } = render(); - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); rerender(); + await waitFakeTimer(); expect(container.querySelector('.values')?.textContent).toEqual(''); rerender(); + await waitFakeTimer(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); }); @@ -195,16 +199,14 @@ describe('useWatch', () => { }; const { container } = render(); - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); + expect(container.querySelector('.values')?.textContent).toEqual( JSON.stringify(['bamboo', 'light']), ); fireEvent.click(container.querySelector('.remove')); - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); + expect(container.querySelector('.values')?.textContent).toEqual( JSON.stringify(['light']), ); @@ -350,9 +352,7 @@ describe('useWatch', () => { }; const { container } = render(); - fireEvent.change(container.querySelector('input'), { - target: { value: 'bamboo' }, - }); + changeValue(container.querySelector('input'), 'bamboo'); expect(updateA > updateB).toBeTruthy(); }); @@ -382,10 +382,9 @@ describe('useWatch', () => { ); }; const { container } = render(); - fireEvent.change(container.querySelector('input'), { - target: { value: 'bamboo' }, - }); + changeValue(container.querySelector('input'), 'bamboo'); container.querySelector('button').click(); + expect(container.querySelector('.value')?.textContent).toEqual('bamboo'); }); it('stringify error', () => { @@ -416,9 +415,10 @@ describe('useWatch', () => { expect(container.querySelector('.value')?.textContent).toEqual(''); fireEvent.click(container.querySelector('.setUpdate')); expect(container.querySelector('.value')?.textContent).toEqual('default'); - fireEvent.change(container.querySelector('input'), { - target: { value: 'bamboo' }, - }); + // fireEvent.change(container.querySelector('input'), { + // target: { value: 'bamboo' }, + // }); + changeValue(container.querySelector('input'), 'bamboo'); expect(container.querySelector('.value')?.textContent).toEqual('bamboo'); expect(errorSpy).not.toHaveBeenCalledWith( 'Warning: useWatch requires a form instance since it can not auto detect from context.', @@ -481,14 +481,11 @@ describe('useWatch', () => { }; const { container } = render(); - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); expect(logSpy).toHaveBeenCalledWith('bamboo', undefined); // initialValue + fireEvent.click(container.querySelector('.test-btn')); - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); expect(logSpy).toHaveBeenCalledWith('light', undefined); // after setFieldValue logSpy.mockRestore(); @@ -510,9 +507,8 @@ describe('useWatch', () => { }; const { container } = render(); - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); + expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); const input = container.querySelectorAll('input'); await changeValue(input[0], 'bamboo2'); @@ -572,10 +568,7 @@ describe('useWatch', () => { }; const { container } = render(); - - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); // Initial expect(container.querySelector('.values')?.textContent).toEqual( @@ -584,10 +577,7 @@ describe('useWatch', () => { // Remove index 1 fireEvent.click(container.querySelector('.remove')!); - - await act(async () => { - await timeout(); - }); + await waitFakeTimer(); // Final expect(container.querySelector('.values')?.textContent).toEqual( From acf49c2abdebffabe8cd52b5522ce749f8dc09eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 19 Dec 2025 15:17:32 +0800 Subject: [PATCH 7/9] test: simplify --- tests/common/index.ts | 9 ---- tests/dependencies.test.tsx | 9 ++-- tests/index.test.tsx | 16 ++++--- tests/useWatch.test.tsx | 90 +++++++++++++++---------------------- tests/validate.test.tsx | 9 ++-- 5 files changed, 58 insertions(+), 75 deletions(-) diff --git a/tests/common/index.ts b/tests/common/index.ts index a9e04015..5e5b39cd 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -26,15 +26,6 @@ export function getInput( const nativeSetTimeout = window.setTimeout; -export async function waitFakeTimer() { - for (let i = 0; i < 10; i += 1) { - await act(async () => { - jest.advanceTimersByTime(1000); - await Promise.resolve(); - }); - } -} - export async function changeValue(wrapper: HTMLElement, value: string | string[]) { const values = Array.isArray(value) ? value : [value]; diff --git a/tests/dependencies.test.tsx b/tests/dependencies.test.tsx index a06facd3..deeb0fc7 100644 --- a/tests/dependencies.test.tsx +++ b/tests/dependencies.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { FormInstance } from '../src'; import Form, { Field } from '../src'; -import timeout from './common/timeout'; +import timeout, { waitFakeTime } from './common/timeout'; import InfoField, { Input } from './common/InfoField'; import { changeValue, matchError, getInput } from './common'; import { fireEvent, render } from '@testing-library/react'; @@ -100,6 +100,8 @@ describe('Form.Dependencies', () => { }); it('should work when field is dirty', async () => { + jest.useFakeTimers(); + let pass = false; const { container } = render( @@ -135,8 +137,7 @@ describe('Form.Dependencies', () => { ); fireEvent.submit(container.querySelector('form')!); - await timeout(); - // wrapper.update(); + await waitFakeTime(); matchError(getInput(container, 0, true), 'You should not pass'); // Mock new validate @@ -149,6 +150,8 @@ describe('Form.Dependencies', () => { fireEvent.click(container.querySelector('button')!); await changeValue(getInput(container, 1), 'light'); matchError(getInput(container, 0, true), false); + + jest.useRealTimers(); }); it('should work as a shortcut when name is not provided', async () => { diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 24c37a6d..526ba868 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -3,9 +3,9 @@ import { resetWarned } from '@rc-component/util/lib/warning'; import React from 'react'; import type { FormInstance } from '../src'; import Form, { Field, useForm } from '../src'; -import { changeValue, getInput, matchError } from './common'; +import { changeValue, getInput, matchError, waitFakeTimer } from './common'; import InfoField, { Input } from './common/InfoField'; -import timeout from './common/timeout'; +import timeout, { waitFakeTime } from './common/timeout'; import type { FormRef, Meta } from '@/interface'; describe('Form.Basic', () => { @@ -311,6 +311,8 @@ describe('Form.Basic', () => { expect(getInput(container).value).toEqual(''); }); it('submit', async () => { + jest.useFakeTimers(); + const onFinish = jest.fn(); const onFinishFailed = jest.fn(); @@ -324,7 +326,7 @@ describe('Form.Basic', () => { // Not trigger fireEvent.submit(container.querySelector('form')); - await timeout(); + await waitFakeTime(); matchError(container, "'user' is required"); expect(onFinish).not.toHaveBeenCalled(); expect(onFinishFailed).toHaveBeenCalledWith({ @@ -332,7 +334,7 @@ describe('Form.Basic', () => { errorFields: [{ name: ['user'], errors: ["'user' is required"], warnings: [] }], outOfDate: false, values: { - user: undefined + user: undefined, }, }); @@ -342,10 +344,12 @@ describe('Form.Basic', () => { // Trigger await changeValue(getInput(container), 'Bamboo'); fireEvent.submit(container.querySelector('form')); - await timeout(); + await waitFakeTime(); matchError(container, false); expect(onFinish).toHaveBeenCalledWith({ user: 'Bamboo' }); expect(onFinishFailed).not.toHaveBeenCalled(); + + jest.useRealTimers(); }); it('getInternalHooks should not usable by user', () => { @@ -903,7 +907,7 @@ describe('Form.Basic', () => { // (setFieldValue internally calls setFields with touched: true) expect(formRef.current.isFieldTouched(['list', 1])).toBeTruthy(); expect(formRef.current.isFieldTouched(['nest', 'target'])).toBeTruthy(); - + // Verify other fields remain untouched expect(formRef.current.isFieldTouched(['list', 0])).toBeFalsy(); expect(formRef.current.isFieldTouched(['list', 2])).toBeFalsy(); diff --git a/tests/useWatch.test.tsx b/tests/useWatch.test.tsx index 3a9a8a39..ac6080d3 100644 --- a/tests/useWatch.test.tsx +++ b/tests/useWatch.test.tsx @@ -3,10 +3,10 @@ import { render, fireEvent, act } from '@testing-library/react'; import type { FormInstance } from '../src'; import { List } from '../src'; import Form, { Field } from '../src'; -import timeout from './common/timeout'; +import { waitFakeTime } from './common/timeout'; import { Input } from './common/InfoField'; import { stringify } from '../src/hooks/useWatch'; -import { changeValue, waitFakeTimer } from './common'; +import { changeValue } from './common'; describe('useWatch', () => { beforeEach(() => { @@ -34,7 +34,7 @@ describe('useWatch', () => { }; const { container } = render(); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); @@ -55,7 +55,7 @@ describe('useWatch', () => { }; const { container } = render(); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); @@ -77,24 +77,24 @@ describe('useWatch', () => { }; const { container } = render(); - await waitFakeTimer(); + await waitFakeTime(); await act(async () => { staticForm.current?.setFields([{ name: 'name', value: 'little' }]); }); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values').textContent)?.toEqual('little'); await act(async () => { staticForm.current?.setFieldsValue({ name: 'light' }); }); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values').textContent)?.toEqual('light'); await act(async () => { staticForm.current?.resetFields(); }); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values').textContent)?.toEqual(''); }); @@ -118,16 +118,16 @@ describe('useWatch', () => { }; const { container, rerender } = render(); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); rerender(); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual(''); rerender(); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); @@ -156,15 +156,15 @@ describe('useWatch', () => { }; const { container, rerender } = render(); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); rerender(); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual(''); rerender(); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); }); }); @@ -199,13 +199,13 @@ describe('useWatch', () => { }; const { container } = render(); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual( JSON.stringify(['bamboo', 'light']), ); fireEvent.click(container.querySelector('.remove')); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual( JSON.stringify(['light']), @@ -481,11 +481,11 @@ describe('useWatch', () => { }; const { container } = render(); - await waitFakeTimer(); + await waitFakeTime(); expect(logSpy).toHaveBeenCalledWith('bamboo', undefined); // initialValue fireEvent.click(container.querySelector('.test-btn')); - await waitFakeTimer(); + await waitFakeTime(); expect(logSpy).toHaveBeenCalledWith('light', undefined); // after setFieldValue logSpy.mockRestore(); @@ -507,7 +507,7 @@ describe('useWatch', () => { }; const { container } = render(); - await waitFakeTimer(); + await waitFakeTime(); expect(container.querySelector('.values')?.textContent).toEqual('bamboo'); const input = container.querySelectorAll('input'); @@ -534,32 +534,30 @@ describe('useWatch', () => { }); it('list remove should not trigger intermediate undefined value', async () => { - const snapshots: any[] = []; + let snapshots: any[] = []; const Demo: React.FC = () => { const [form] = Form.useForm(); const users = Form.useWatch(['users'], form) || []; - - React.useEffect(() => { - snapshots.push(users); - }, [users]); + snapshots.push(users); return ( -
-
{JSON.stringify(users)}
+ {(fields, { remove }) => (
- {fields.map((field, index) => ( + {fields.map(field => ( - {control => ( -
- - remove(1)} /> -
- )} +
))} +
)} @@ -568,27 +566,13 @@ describe('useWatch', () => { }; const { container } = render(); - await waitFakeTimer(); - - // Initial - expect(container.querySelector('.values')?.textContent).toEqual( - JSON.stringify(['bamboo', 'light']), - ); + await waitFakeTime(); + snapshots = []; - // Remove index 1 - fireEvent.click(container.querySelector('.remove')!); - await waitFakeTimer(); - - // Final - expect(container.querySelector('.values')?.textContent).toEqual( - JSON.stringify(['bamboo']), - ); + fireEvent.click(container.querySelector('button'));; + await waitFakeTime(); - // Should not have intermediate state like ['bamboo', undefined] - expect( - snapshots.some( - v => Array.isArray(v) && v.length === 2 && v[0] === 'bamboo' && v[1] === undefined, - ), - ).toBe(false); + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toEqual(['bamboo']); }); }); diff --git a/tests/validate.test.tsx b/tests/validate.test.tsx index 2a9d9227..7fb1cdd9 100644 --- a/tests/validate.test.tsx +++ b/tests/validate.test.tsx @@ -288,6 +288,8 @@ describe('Form.Validate', () => { }); it('form context', async () => { + jest.useFakeTimers(); + const { container, rerender } = render( @@ -299,14 +301,11 @@ describe('Form.Validate', () => { matchError(container, false); // Trigger onBlur - // wrapper.find('input').simulate('blur'); fireEvent.blur(getInput(container)); - await timeout(); - // wrapper.update(); + await waitFakeTime(); matchError(container, true); // Update Form context - // wrapper.setProps({ validateTrigger: 'onChange' }); rerender( @@ -314,6 +313,8 @@ describe('Form.Validate', () => { ); await changeValue(getInput(container), '1'); matchError(container, false); + + jest.useRealTimers(); }); }); From fc1f633f1879ed16f09bfffdd33587d8fe985703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 19 Dec 2025 15:31:35 +0800 Subject: [PATCH 8/9] chore: clean up --- tests/common/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/common/index.ts b/tests/common/index.ts index 5e5b39cd..7f3b43f6 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -104,7 +104,3 @@ export async function validateFields(form, ...args) { await form.validateFields(...args); }); } - -export function executeMicroTasks() { - jest.advanceTimersByTime(1000); -} From 832be046bddd076cfb53bba25385cba76591da54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 19 Dec 2025 15:37:46 +0800 Subject: [PATCH 9/9] chore: clean up --- src/hooks/useForm.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/hooks/useForm.ts b/src/hooks/useForm.ts index b771581d..3353d8e6 100644 --- a/src/hooks/useForm.ts +++ b/src/hooks/useForm.ts @@ -200,27 +200,9 @@ export class FormStore { }; private notifyWatch = (namePath: InternalNamePath[] = []) => { - // // No need to cost perf when nothing need to watch - // if (this.watchList.length) { - // const values = this.getFieldsValue(); - // const allValues = this.getFieldsValue(true); - - // this.watchList.forEach(callback => { - // callback(values, allValues, namePath); - // }); - // } this.watcherCenter.notify(namePath); }; - // private notifyWatchNamePathList: InternalNamePath[] = []; - // private batchNotifyWatch = (namePath: InternalNamePath) => { - // this.notifyWatchNamePathList.push(namePath); - // this.batch('notifyWatch', () => { - // this.notifyWatch(this.notifyWatchNamePathList); - // this.notifyWatchNamePathList = []; - // }); - // }; - // ========================== Dev Warning ========================= private timeoutId: any = null; @@ -652,7 +634,6 @@ export class FormStore { private registerField = (entity: FieldEntity) => { this.fieldEntities.push(entity); const namePath = entity.getNamePath(); - // this.batchNotifyWatch(namePath); this.notifyWatch([namePath]); // Set initial values @@ -693,7 +674,6 @@ export class FormStore { } } - // this.batchNotifyWatch(namePath); this.notifyWatch([namePath]); }; }; @@ -760,7 +740,6 @@ export class FormStore { type: 'valueUpdate', source: 'internal', }); - // this.batchNotifyWatch(namePath); this.notifyWatch([namePath]); // Dependencies update