前言:antd v5 的 message.error 底层依赖 ReactDOM.render,React 19 将其移除后调用静默失败,登录错误提示不显示。用 @ant-design/v5-patch-for-react-19 补丁修复后,发现 E2E 测试假阳性,全绿但功能损坏,测试只检查 URL 未验证 message 文本。
前两天我在搞一个项目,登录页面出了个特别诡异的Bug。
你输错密码,点登录,页面纹丝不动。
没有红字提示,没有弹窗,没有任何反馈。就好像你根本没点过那个按钮一样。
我当时就懵了。

第一反应是,是不是网络请求没发出去。打开控制台看了一眼,请求发了,后端也返回了「用户名或密码错误」。数据都对,但前端就是没弹提示。
这就很离谱了。
然后我注意到控制台里有行小字,一直没在意的那种。
Warning: [antd: compatible] antd v5 support React is 16 ~ 18
antd v5 支持的 React 版本是 16 到 18。而这个项目用的是 React 19。
我一开始没当回事,觉得不就是个版本警告嘛,能跑就行。但越想越不对劲,错误提示不出来,会不会跟这个有关?
顺着这条线往下挖,还真让我给挖到了。
antd v5 的 message.error() 这种静态方法,底层用的是 ReactDOM.render 来创建一个独立的 React 根节点,把提示信息挂上去。但 React 19 把 ReactDOM.render 给删了。直接移除,不是废弃,是删了。
所以 message.error() 调用的时候,内部走到 ReactDOM.render 那一步,直接静默失败。不报错,不抛异常,就是什么都不做。
你想想看这个设计有多坑。一个函数调用了,没报错,没异常,返回值也正常,但它该做的事一件都没做。这就像你按了电梯按钮,灯亮了,电梯也到了,门开了,但里面是空的,不是电梯,是一堵墙。

说真的,我之前一直觉得「静默失败」这个词离我很远,直到这次亲自撞上。
搞过工程的人都知道,最怕的不是程序崩溃。崩溃了你至少知道出事了,能去查。最怕的是程序「看起来正常运行」,但实际上该干的活一件没干。你信它没问题,它就真的不给你任何信号。
航空业有个说法叫「静默崩溃」,指的是那种系统已经出问题了,但仪表盘上什么警报都没有的情况。很多空难的事后调查里,黑匣子记录的数据显示系统早就异常了,但驾驶舱里安安静静,飞行员根本不知道。
这次这个Bug,就是一个微型版的静默崩溃。
找到根因之后,修复倒不难。antd 官方出了一个兼容包,叫 @ant-design/v5-patch-for-react-19,原理是用 unstableSetRender 把 ReactDOM.render 替换成 React 19 的 createRoot API。装上这个包,再在根布局里引入一下就行。
不过这里有个坑。Next.js App Router 的 layout.tsx 是服务端组件,你在这个文件里 import 一个带副作用的客户端模块,Next.js 不保证它的副作用会在 antd 渲染之前执行。所以我单独写了个客户端组件 AntdPatch.tsx,在里面 import 补丁包,然后用它包裹 children。这样就能确保补丁先加载,antd 后渲染,顺序对了。
补丁打上,重启 dev server,再输错密码,红字提示「用户名或密码错误」正常弹出来了。

到这儿,Bug算是修了。
但事情没完。
我在修这个Bug的过程中,发现了一个更让我后背发凉的事。
这个项目的 E2E 测试,全绿的。
14个认证相关的测试,全过。包括「错误密码登录」这个场景,测试是有的,而且是通过的。
但这个功能明明是坏的。
我翻了一下测试代码,发现问题了。「错误密码登录」这个测试,它只检查了一件事,URL 是否还停留在 /login 页面。它没有检查错误提示是否出现。
// 只检查URL,message静默失败时测试照样过
await expect(page).toHaveURL(/\/login/, { timeout: 5000 });
你输错密码,页面没跳走,URL 还在 /login,测试一看,嗯,符合预期,通过。至于用户该看到的错误提示有没有弹出来,它根本没看。
最骚的是,这个项目的测试设计文档里,白纸黑字写了这条测试应该断言 message"用户名或密码错误"。设计是对的,但实现的时候漏了。文档写了,代码没写,测试还过了。
给我一下子整不会了。

我顺着这条线又翻了一遍其他测试,果然不止这一处。注册时用户名已存在、修改密码时旧密码错误、新增小程序时AppID重复,这几个场景的测试全都只检查了「页面没跳走」或「数据库状态没变」,没有一个去验证错误提示是否出现。
四个测试,四个漏洞,全都是同一个模式。触发了 message.error(),但 message.error() 是坏的,而测试又没检查 message.error() 的输出,所以测试全绿,功能全坏。
这四个测试我一个个修了,每个都加上了 getByText() 断言,去验证那条错误提示真的出现在屏幕上。修完跑了一遍,全过。
但说实话,真正让我在意的不只是这四个测试。
我一直在想一个问题,为什么这种事会发生?
你想想看,这个项目的测试设计文档写得挺规范的,每个测试场景该断言什么,列得清清楚楚。但到了写代码的时候,人在实现测试的时候,很自然地就只写了最容易验证的那部分。URL 没变,数据库没变,这些是「硬」的,好断言。但「屏幕上有没有出现一行红字」,这个是「软」的,容易被忽略。
而且这种遗漏特别隐蔽。因为测试是绿的,你不会去怀疑它。CI 跑过了,PR 合了,所有人都觉得没问题。直到有一天,有个用户输错了密码,发现页面什么反应都没有,才会有人回头来看。
这让我想起一个概念,叫「测试的假阳性」。不是测试本身写错了,是测试验证的东西跟你以为它验证的东西不是一回事。你以为它在验证「登录失败会提示用户」,实际上它只验证了「登录失败不会跳转」。这两件事听起来差不多,但差了一个 message.error() 的距离,而这个距离刚好够吞掉一个Bug。
坦率的讲,这次的事让我对「测试全绿」这件事多了一层警惕。绿灯不是终点,绿灯只是说「你检查过的那些东西没问题」。你没检查的呢?它不知道,它也不会告诉你。
就像那个静默失败的 message.error() 一样。

顺着这个事再聊两句。
修完Bug之后我去看了一眼 antd v6 的 changelog,从 6.0.0 到 6.6.3,差不多迭代了十个月。如果升级到 v6,这次这个 Bug 的根因就直接消除了,因为 v6 原生支持 React 19,不需要打补丁。
但说实话,比起「不用打补丁」这个收益,v6 有个东西更让我心动。
语义化 DOM。
现在这个项目里有十几处样式定制,用的是 :global(.ant-card-head) 这种选择器。这玩意儿本质上是在耦合 antd 的内部 DOM 结构。antd 哪天改一下内部 class 名,你的样式就废了。v6 给所有组件加了 classNames 和 styles 这套语义化 API,你可以直接通过 styles={{ header: {...} }} 来定制,不用再去猜内部 DOM 长什么样。
这个收益是长期的。每次 antd 升级,你不用再提心吊胆地检查那些 :global 选择器有没有失效。
另外 v6 还有个 CLI 工具,npx antd setup,能自动扫你代码里用了哪些 deprecated API。以前升级全靠人肉对照迁移文档,现在有了自动扫描,省心不少。
还有个 Wave 组件的内存泄漏修复,就是按钮点击波纹效果那个。v6.0.1 修了一个 RAF 没清理的问题,项目里所有按钮都有波纹效果,长期用下来会慢慢泄漏。这种 Bug 你平时根本感知不到,但它就在那儿,一点一点吃你的内存。
又是一个静默的问题。
说到底,这次这个 Bug 给我最大的感触就一句话。
最危险的失败,不是崩溃,是沉默。
崩溃了你会去修。沉默了你会以为没事。
测试全绿你会放心。但你没测的那些东西,正在沉默地坏着。
我修完这个 Bug 之后,在项目的 AGENTS.md 里加了一条规则。操作失败或成功时,E2E 测试必须验证 antd message 文本出现在屏幕上,不能只检查 URL 或数据库状态。
这条规则,早该有的。

最后说一句题外话。
我有时候觉得,写代码这件事跟做人挺像的。你最该警惕的,不是那些大吵大闹告诉你「我出问题了」的信号,而是那些安安静静、什么反应都没有的时刻。
控制台里那行小字,antd v5 support React is 16 ~ 18,它一直在那儿。从项目第一天就在那儿。每个人都看见过,没人当回事。
它不是没告诉你。
是你没听。
/ 注意,本项目仅在 SyncMein 的500人小群分享。
/ 谢谢你看完了我的文章,我们下次再见吧。
/ 作者:明察
/ 投稿或爆料,请联系邮箱:mingcha@gqmg.com













暂无评论内容