DEBUG.WIKI
野草云;香港 200Mbps 峰值;不限月流量 · BGP VPS;查看套餐 →
晚安云;香港 CN2 ¥15/月
V.PS:让项目靠近用户,亚太、欧洲、北美 KVM VPS

React 19.2.8 两端 DOM 一致仍报 #418,检查挂起后的水合重放

React 19.2.8 并发水合遇到带状态协议的 lazy 子节点,恢复时旧游标可能错位。区分真实首帧差异,选择正式包含恢复逻辑的渲染器组合。

服务端已经输出完整元素,客户端也使用同一棵组件树,React 却报 Minified React error #418,随后可能丢弃已经输出的 DOM,退回客户端重新生成。如果差异提示把父级 <div> 与它内部的 <section> 对比,要检查 React 19.2.8 的水合重放路径,不必先改日期格式或给元素加随机 key。

这条路径需要几个条件同时出现:正在进行可分片的并发水合;宿主元素的子位置包含尚未就绪的 lazy 节点;其 thenable 带有 React 可以读取的 status、value 状态协议;React 因它挂起后,节点又在恢复渲染前就绪。不带这类状态的普通 Promise 会走不同分支,不能只凭“加载期间挂起过”就归到这里。普通的 <LazyComponent />、晚一些才完成的加载,也未必经过同一路径。只凭 #418 不能确认是这个问题。

先判断提示中的元素是否错了一层

旧实现认领父级 DOM 后,水合游标已经走到父元素内部。若这时解析 lazy 子节点发生挂起,之后重放父元素,却没有把游标退回原位置,就会拿内部的子节点去匹配父元素。

因此,两端内容确实可以相同,错误却表现得像标签不同。在较大的树里,连续出现的相同标签还可能暂时掩盖游标偏移,直到更深处才报告差异;最后被点名的业务组件不一定就是触发位置。

在已有客户端入口的受控诊断中,可记录 recoverable error 和组件栈。下面的 container、App 沿用应用原入口,代码需要现有 JSX 构建环境:

import { startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';

startTransition(() => {
  hydrateRoot(container, <App />, {
    onRecoverableError(error, info) {
      console.error(error.message, info.componentStack);
    },
  });
});

这不是替换框架启动器的要求。Next.js 等框架已经管理 hydrateRoot 时,应使用其既有错误采集和构建诊断入口,不再额外创建第二个根。还要保存当次返回的 HTML、实际客户端构建版本和资源加载时序,区分真实首帧差异与恢复渲染时的错误认领。

若两边本来就分别生成当前日期、随机值或不同初始数据,先处理那些确定差异。日期场景可对照统一服务端与客户端的首帧文本;这里针对的是两端树一致、仍因重放状态出错的分支。

使用正式包含游标恢复逻辑的组合

React 19.3.0 的正式源码已在宿主元素重放之前调用 popHydrationStateOnInterruptedWork,19.3.1 仍保留该处理;19.2.8 的对应位置没有这一步。采用兼容应用与框架、且包含该逻辑的正式版本,比直接修改 react-dom 发布文件更容易维护。

独立 React 应用要一起核对 react 与 react-dom,保留锁文件并重新构建客户端和服务端产物。框架内置的 React canary 或编译产物可能由框架控制:只在 package.json 改一个 react-dom 版本,不证明框架实际运行的渲染器已经替换。

不要用 suppressHydrationWarning 隐藏这种结构认领错误,也不要把整站改成客户端渲染来当作故障已修。移除 startTransition 可能使某个受控样例绕开重放,但会改变调度路径,不能由一次没有报错推断整个应用恢复正确。

验收仍应保留原来的 SSR、并发入口和加载条件,重复冷启动,观察错误采集是否消失,并确认已经认领的服务端节点被复用。页面能显示只是较弱结果:客户端重新生成一遍页面也能显示。需要回退时一起恢复依赖、锁文件与两端构建产物,避免服务端和浏览器继续使用不同批次的代码。