#4361·formily

<StrictMode> + React 19: form 实例被 dispose 导致 form.mounted=false、fields 为空,表单完全失效

Author: NexxLuoCreated Jun 18, 2026Updated Jun 18, 2026

<StrictMode> + React 19: form 实例被 dispose 导致 form.mounted=falsefields 为空,表单完全失效

版本

@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=falseform.lifecycle=undefinedform.fields 为空。SchemaField 虽然渲染了 DOM,但字段没有注册到 form,getFormGraph() 只返回根节点,所有字段事件、按钮 onClick 全部失效。

复现步骤

typescript
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=trueform.fields 包含子字段、getFormGraph() 返回完整字段树。

实际行为

form.mounted: false
form.lifecycle: undefined
form.fields: []
getFormGraph(): { "": { mounted: false, ... } }  // 只有根节点

根因分析

@formily/reactive-reactuseCompatEffect(useAttach 内部使用)中:

javascript
// @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 执行顺序为:

  1. mount effect:form.onMount() 执行
  2. cleanup:mountedRef.current=false,然后 immediate 回调里 dispose()(即 form.onUnmount())执行 → form 响应式系统被销毁
  3. 重新 mount effect:form.onMount() 再次执行,但 form 已被 dispose,内部状态无法恢复

当 form 用 useMemo 创建(依赖不变,第二次 mount 复用同一实例)时,第二次 mount 的 useAttach(form) attach 的是已被 dispose 的死 form

关键矛盾:useCompatEffectimmediate(其实是 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

  1. FormProvider 之外手动管理 form 生命周期(自定义 SafeFormProvider,用 setTimeout(0) 跳过 StrictMode 虚假 unmount):无效。因为 formily 的子组件(Field/RecursionField)各自独立使用 useCompatFactory,它们的 dispose 不受 FormProvider 层控制。
  2. 移除 <StrictMode>:有效,但牺牲了 StrictMode 的开发期检查能力。

期望的修复方向

希望 @formily/reactive-reactuseCompatEffect 能正确处理 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