2026.0808 CLOUDY

一次时隐时现的 Next.js 水合错误排查

问题是什么

首页 SectionA 有两套进入动画:

  • 首次访问时,六边形从上下两个方向交错进入。
  • 当前标签页内重访时,所有六边形统一从上方向下进入。

是否重访通过 sessionStorage.sectionAPlayed 判断。问题发生时,React 会报告:

A tree hydrated but some attributes of the server rendered HTML
didn't match the client properties. This won't be patched up.

错误中最重要的差异不是 clip-pathwill-change 等属性的书写形式,而是同一个六边形的 transform

+ transform: translate(62%, -100%)
- transform: translate(-62%, 100%)

客户端认为它应该在右上方,服务端 HTML 却把它放在左下方。这正好对应“重访统一进入”和“首次交错进入”两套起点。

更麻烦的是,这个问题并不总出现。有时重访完全正常,有时突然报警并变成交错进入;切换黑白主题后,出现概率似乎也会变化。

最初的错误实现

问题来自客户端组件首次渲染时直接读取 sessionStorage

const [played] = useState(() =>
  !PLAY_EVERY_TIME &&
  typeof window !== 'undefined' &&
  sessionStorage.getItem('sectionAPlayed') === 'true'
);

随后,首帧位置直接依赖 played

if (phase === 'initial') {
  return played
    ? 'translate(62%, -100%)'
    : index % 2 === 1
      ? 'translate(-62%, 100%)'
      : 'translate(62%, -100%)';
}

虽然文件带有 'use client',但 Next.js App Router 仍会在服务端预渲染 Client Component。'use client' 表示这是客户端边界,并不表示组件只在浏览器中渲染。

因此同一段初始化代码会得到两个结果:

环境 能否读取 sessionStorage played
服务端预渲染 不能,没有 window false
浏览器首次渲染 由当前标签页的记录决定

sectionAPlayed === 'true' 时:

服务端:played = false -> 输出交错起点
客户端:played = true  -> 期望统一上方起点

服务端 HTML 和客户端第一次 React 渲染从一开始就不一致,这就是根因。

如何复现

在当前标签页中清除播放记录,然后刷新:

sessionStorage.removeItem('sectionAPlayed');
location.reload();

这次服务端和客户端都得到 false,所以通常不会产生水合错误,并播放首次访问的交错动画。动画开始后,页面会写入:

sessionStorage.setItem('sectionAPlayed', 'true');

不清除记录,再进行一次硬刷新:

location.reload();

此时服务端仍只能得到 false,客户端却得到 true,水合输入已经不一致。只要 React 对这部分服务端 DOM 执行正常水合,就会复现警告。

也可以直接制造重访状态:

sessionStorage.setItem('sectionAPlayed', 'true');
location.reload();

需要注意:在原来的异步渲染结构下,“没有看到警告”不代表两端已经一致。它也可能意味着该 Suspense 边界这一次没有按相同路径完成水合。

为什么报错后反而交错进入

React 的提示中有一句很关键:

This won't be patched up.

这类属性水合不一致发生后,React 不保证把服务端已有属性修正为客户端值。此时可能出现:

  1. 服务端先输出交错的 transform
  2. 客户端首次渲染期望统一从上方进入。
  3. React 发现不一致并报警,但保留了已有的服务端 style。
  4. 后续 phase 切换为 entering,目标位置变为 translate(0, 0)
  5. 浏览器从保留下来的交错起点执行过渡。

所以“出现水合错误”和“动画重新变成交错进入”其实是同一个问题的两个表现。

为什么它时好时坏

首页不是同步加载的。它经过了以下结构:

<ThemeProvider>
  <Suspense fallback={null}>
    <LazyHomePageClient />
  </Suspense>
</ThemeProvider>

同时,Next.js 会把服务端渲染结果以流式响应发送给浏览器。最终行为受到多个时序影响:

  • HomePageClient 异步 chunk 何时下载完成。
  • Suspense 边界何时从 fallback 切换到真实内容。
  • 服务端流式 HTML 何时插入页面。
  • next-themes 何时恢复 data-theme 并引起 Provider 更新。
  • 开发环境的模块缓存、Fast Refresh 和 React Strict Mode。
  • 浏览器前进后退缓存是否直接恢复已有页面。

如果 React 正常水合了服务端的 SectionA,就会比较出不同的 transform,随后报警并可能保留交错起点。

如果这个边界因为加载时序而改走客户端渲染,浏览器会直接用 played = true 创建 DOM。此时没有同一批服务端属性可供比较,于是不报警,动画也会正确地统一从上方进入。

这就是错误时隐时现的原因:数据冲突一直存在,只是并非每次都通过同一条 React 渲染路径暴露出来。

为什么一度看起来和主题有关

实际测试中曾观察到:黑色主题容易报错,白色主题不报错。于是最初怀疑主题变量或 next-themes 改变了六边形样式。

但代码检查表明:

  • SectionA 不读取当前主题。
  • 主题只切换 data-theme 和 CSS 变量。
  • 主题逻辑不修改 sectionAPlayed
  • CSS 变量无法把 translate(62%, -100%) 改成 translate(-62%, 100%)

随后使用全新的无头浏览器会话,分别预置相同的 sectionAPlayed = true 和不同主题,并在页面代码执行前拦截 sessionStorage.getItem。两个主题下,SectionA 首次客户端渲染都确实读取到了 true

更有意义的是,隔离环境得到了与人工测试相反的结果:白色主题报警,黑色主题不报警,而且结果在该环境中可以重复。

这证明主题不是数据差异的根因。主题恢复只是改变了 Provider 和 Suspense 的调度时序,从而影响“这一次是否走到会报警的水合路径”。缓存、机器速度或开发服务器状态改变后,这种表面关联完全可能反转。

不可靠的修复方式

只加 suppressHydrationWarning

这只能隐藏警告,不能保证错误的服务端 transform 被修正。动画仍可能从交错位置开始。

用 visibility 隐藏六边形

如果仍然渲染节点,只是设置 visibility: hidden,React 依然会执行:

style={hexagonMaskStyle(index)}

也就仍然会在播放状态尚未确定时调用 getTransformStyle()。DOM 属性冲突没有消失,只是用户暂时看不到。

在 useEffect 中读取,但先渲染默认起点

服务端和客户端首帧虽然能保持一致,但重访时会先把“首次访问的交错起点”放进 DOM,再切换成“重访的上方起点”。如果浏览器绘制了中间状态,就会闪烁或播放错误动画。

通过主题分支规避

主题只是影响异步调度的旁路因素。针对主题加条件只会把问题转移到另一种缓存或加载环境中。

最终的稳定方案

解决方案是把“尚未读取”和“首次访问”分成两个不同状态:

const [played, setPlayed] = useState<boolean | null>(null);

useEffect(() => {
  setPlayed(
    !PLAY_EVERY_TIME &&
    sessionStorage.getItem('sectionAPlayed') === 'true'
  );
}, []);

null 不代表首次访问,而是代表浏览器还没有完成读取。

在此期间,六边形节点完全不创建:

<div className="hexagon-wrapper" style={hexagonWrapperStyle()}>
  {played !== null && IMAGES.map((item, index) => (
    <div
      className="hexagon-mask relative group"
      key={index}
      style={hexagonMaskStyle(index)}
    >
      {/* ... */}
    </div>
  ))}
</div>

这条约束很重要。在 played === null 时:

  • 没有 .hexagon-mask DOM。
  • 不执行 IMAGES.map()
  • 不调用 hexagonMaskStyle()
  • 不调用 getTransformStyle()
  • SSR 和客户端首帧都只输出相同的空容器。

读取完成后,React 才根据确定的 played 创建带有正确起点的节点。

为什么还需要两次 requestAnimationFrame

节点挂载后不能立刻切换到 entering。如果 React 提交初始位置后马上提交终点,浏览器可能把两次更新合并到同一帧,结果是没有过渡动画。

最终实现会等待两次动画帧:

useEffect(() => {
  if (played === null || phase !== 'initial' || isLoading) return;

  let enterFrame = 0;
  const mountedFrame = requestAnimationFrame(() => {
    enterFrame = requestAnimationFrame(() => {
      setPhase('entering');
      sessionStorage.setItem('sectionAPlayed', 'true');
    });
  });

  return () => {
    cancelAnimationFrame(mountedFrame);
    cancelAnimationFrame(enterFrame);
  };
}, [isLoading, phase, played]);

它形成了明确的渲染顺序:

SSR / 客户端首帧
  played = null
  只渲染空容器
        ↓
useEffect 读取 sessionStorage
        ↓
挂载六边形,并写入正确的初始 transform
        ↓
浏览器绘制初始位置
        ↓
第二个 requestAnimationFrame
        ↓
phase = entering,过渡到 translate(0, 0)

这样首次访问仍然从交错位置进入,重访仍然统一从上方进入,但任何六边形 DOM 都不会在播放状态确定前获得 transform

验证结果

修复后进行了四组隔离检查:

主题 播放记录 六边形首次 DOM 状态 水合错误
light 上下交错
light 全部在上方
dark 上下交错
dark 全部在上方

同时通过 TypeScript 检查:

npx.cmd tsc --noEmit

稳定性的关键不在于让 React 忽略差异,而在于让不确定数据根本不参与 SSR 和 hydration,并确保依赖该数据的 DOM 只在数据确定后创建。

排查期间出现的次生故障

隔离测试期间还遇到过另一个 Next.js 错误:

Invariant: Expected clientReferenceManifest to be defined.
This is a bug in Next.js.

它与 SectionA 的水合逻辑不是同一个问题。原因是为了测试曾在同一个项目目录同时启动两个 next dev:原服务使用 3000,临时服务使用 3015。

端口虽然不同,但两个进程默认仍然共用同一个 .next 输出目录。它们会竞争写入:

.next/server/app/page.js
.next/server/app/page_client-reference-manifest.js

当一个进程读取到另一个进程正在重建或已经覆盖的产物时,页面路由就可能找不到与自身编译状态匹配的 client reference manifest。

处理方式是:

  1. 确保同一工作目录只运行一个 Next dev server。
  2. 停止开发服务。
  3. 删除项目内的 .next 生成缓存。
  4. 重新启动开发服务,让 manifest 完整重建。

如果确实需要并行启动两个 Next 实例,应该为它们配置不同的构建目录,或者使用独立 worktree,而不能只修改端口。

最后得到的原则

这次问题可以归纳为几条规则:

  1. Client Component 仍可能参与服务端预渲染。
  2. localStoragesessionStorage、视口尺寸等浏览器数据不能决定 hydration 首帧的 HTML 属性。
  3. “没有水合警告”不一定代表服务端和客户端一致,也可能是 Suspense 边界改走了客户端渲染。
  4. 动画的起始状态必须先被浏览器实际绘制,再切换到目标状态。
  5. 未知状态要显式建模为 nullpending,不能与业务上的 false 混为一谈。
  6. 如果某组 DOM 的属性依赖客户端数据,最稳妥的方式是在数据确定前不创建这组 DOM。
  7. 同一工作目录不要同时运行多个共享 .next 的开发服务器。

真正稳定的修复不是消除控制台提示,而是消除产生不确定首帧的结构。