<StrictMode> + React 19: form 实例被 dispose 导致 form.mounted=false、fields 为空,表单完全失效
<StrictMode> + React 19: form 实例被 dispose 导致 form.mounted=false、fields 为空,表单完全失效
版本
@formily/react: 2.3.7
@formily/reactive-react: 2.3.7
@formily/core: 2.3.7
react: 19.2.5
react-dom: 19.2.5问题描述
在 React 19 的 <StrictMode> 下,使用 formily 的 <FormProvider> + useMemo(createForm) 模式时,form 实例在 StrictMode 的双调用 effect(mount→unmount→mount)中被 useAttach/useCompatFactory 的 cleanup 逻辑 dispose,导致第二次 mount 时 form 已是"死"实例:form.mounted=false、form.lifecycle=undefined、form.fields 为空。SchemaField 虽然渲染了 DOM,但字段没有注册到 form,getFormGraph() 只返回根节点,所有字段事件、按钮 onClick 全部失效。
复现步骤
import React, { useMemo } from 'react';
import { FormProvider, createSchemaField } from '@formily/react';
import { createForm } from '@formily/core';
const SchemaField = createSchemaField({ components: {} });
const MyForm = () => {
// 用 useMemo 创建 form(常见模式,schema 不变时不重建)
const form = useMemo(() =>
createForm({
effects: () => {},
}), []
);
return (
<FormProvider form={form}>
<SchemaField schema={{
type: 'object',
properties: {
input: { type: 'string', title: 'Input', 'x-decorator': 'FormItem', 'x-component': 'Input' },
},
}} />
</FormProvider>
);
};
// 在 StrictMode 下渲染
ReactDOM.createRoot(root).render(
<React.StrictMode>
<MyForm />
</React.StrictMode>
);预期行为
StrictMode 下表单正常工作:form.mounted=true、form.fields 包含子字段、getFormGraph() 返回完整字段树。
实际行为
form.mounted: false
form.lifecycle: undefined
form.fields: []
getFormGraph(): { "": { mounted: false, ... } } // 只有根节点根因分析
在 @formily/reactive-react 的 useCompatEffect(useAttach 内部使用)中:
// @formily/reactive-react/lib/hooks/useCompatEffect.js
var useCompatEffect = function (effect, deps) {
var mountedRef = useRef(false);
useEffect(function () {
mountedRef.current = true;
var dispose = effect(); // ← mount: form.onMount()
return function () {
mountedRef.current = false;
if (!isEqualDeps(depsRef.current, deps)) {
if (dispose) dispose();
return;
}
immediate(function () {
if (mountedRef.current) return;
if (dispose) dispose(); // ← StrictMode 第一次 cleanup: form.onUnmount() → dispose!
});
};
}, deps);
};在 React 19 StrictMode 下,effect 执行顺序为:
- mount effect:
form.onMount()执行 - cleanup:
mountedRef.current=false,然后immediate回调里dispose()(即form.onUnmount())执行 → form 响应式系统被销毁 - 重新 mount effect:
form.onMount()再次执行,但 form 已被 dispose,内部状态无法恢复
当 form 用 useMemo 创建(依赖不变,第二次 mount 复用同一实例)时,第二次 mount 的 useAttach(form) attach 的是已被 dispose 的死 form。
关键矛盾:useCompatEffect 的 immediate(其实是 setTimeout(0)/Promise.resolve().then)延迟 dispose 的设计,在 React 19 的同步双调用下来不及阻止 dispose——React 19 StrictMode 的 cleanup→mount 是同步连续发生的。
影响范围
<FormProvider>的 form 实例被 dispose- 内部所有
<Field>/<RecursionField>组件通过useCompatFactory/useAttach创建的 field 实例也各自被独立 dispose(第二次 mount 时 attach 到已 dispose 的 field) - 结果:整个表单的字段树不建立,所有交互失效
尝试过的 workaround
- 在
FormProvider之外手动管理 form 生命周期(自定义 SafeFormProvider,用setTimeout(0)跳过 StrictMode 虚假 unmount):无效。因为 formily 的子组件(Field/RecursionField)各自独立使用useCompatFactory,它们的 dispose 不受 FormProvider 层控制。 - 移除
<StrictMode>:有效,但牺牲了 StrictMode 的开发期检查能力。
期望的修复方向
希望 @formily/reactive-react 的 useCompatEffect 能正确处理 React 19 StrictMode 的双调用——在第一次 cleanup 时不立即 dispose,而是检测到 StrictMode 的快速重新 mount 后跳过 dispose。可能的方案:
- 用 React 19 新增的
useId或专门的 StrictMode 检测来区分"真实卸载"与"StrictMode 虚假卸载" - 或者调整
immediate的时序,确保在 StrictMode 的同步 cleanup→mount 序列中不触发 dispose
环境
- OS: macOS
- 浏览器: Chrome
- React: 19.2.5
- formily: 2.3.7
Source: alibaba/formily