# 如何修复AI导致的React水合不匹配

> AI代理生成的React组件在服务器端和客户端渲染出不同的HTML，导致首次加载时出现水合错误和界面损坏。

**Type:** Failure  
**Tools:** Cursor, Claude Code, Codex, Windsurf  
**Stack:** Next.js, Astro, TypeScript  
**Updated:** 2026-06-08

---

代理编写的组件在服务器端和客户端产生不同的HTML——日期、随机值、`window`检查——导致React抛出水合不匹配错误并在加载时重新渲染整个树。

## 症状

组件在渲染过程中读取`Date.now()`、`Math.random()`或`window`，导致服务器端和客户端输出不同。

```tsx
// WRONG — hydration mismatch
export function Timestamp() {
  return <p>Page loaded at: {new Date().toLocaleTimeString()}</p>;
  // Server renders "10:00:00 AM", client renders "10:00:01 AM" — mismatch
}

// WRONG — conditional on window
export function ThemeIcon() {
  const isDark = typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
  return <span>{isDark ? "🌙" : "☀️"}</span>;
  // Server: always renders "☀️"; client: may render "🌙" — mismatch
}
```

浏览器控制台显示：

```
Error: Hydration failed because the initial UI does not match what was rendered
on the server.
```

## 原因

代理编写的组件单独看似乎正确。但它没有模拟两次独立的渲染过程（SSR + 客户端水合），因此不知道两者之间任何不同的值都会破坏水合。

## 如何识别

- 直接在JSX或渲染过程中调用`Date.now()`、`new Date()`、`Math.random()`，而没有放在`useEffect`或`useState`初始化器中。
- 在渲染返回中使用`typeof window !== "undefined"`守卫——服务器始终走`false`分支。
- 在渲染过程中读取`localStorage`、`sessionStorage`或`navigator`。
- 服务器和客户端输出之间HTML标签或属性值不匹配（在React DevTools的水合警告中可见）。

## 如何修复

将仅浏览器可用的值延迟到挂载后，或者在服务器端生成稳定值并通过props传递。

```tsx
// CORRECT — defer client-only value to after mount
"use client";
import { useState, useEffect } from "react";

export function Timestamp() {
  const [time, setTime] = useState<string | null>(null);

  useEffect(() => {
    setTime(new Date().toLocaleTimeString());
  }, []);

  if (!time) return <p>Loading time…</p>; // matches server output
  return <p>Page loaded at: {time}</p>;
}
```

```tsx
// CORRECT — theme icon: use CSS / data attribute, avoid JS render branch
// Set data-theme on <html> in a blocking inline script (not React)
// Then use CSS: [data-theme="dark"] .icon { content: "🌙" }

// Or use Next.js next-themes which handles SSR safely:
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";

export function ThemeIcon() {
  const { resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return <span style={{ width: 24 }} />; // stable placeholder
  return <span>{resolvedTheme === "dark" ? "🌙" : "☀️"}</span>;
}
```

```txt
[ ] No Date.now() / Math.random() / new Date() called directly during render
[ ] No typeof window / localStorage / navigator accessed during render
[ ] Browser-only state initializes to null/undefined, set in useEffect
[ ] Render a stable placeholder (same as server output) until after mount
[ ] Use suppressHydrationWarning only on elements where the mismatch is intentional (e.g. timestamp)
[ ] Run "next build && next start" and check browser console for hydration errors
```

## 修复提示

```txt title="Fix Prompt"
This component causes a React hydration mismatch because it reads browser-only
values (Date, window, localStorage, Math.random) during render. Fix it: move
all browser-only reads into a useEffect that sets state after mount, render a
stable placeholder on first render that matches what the server produces, and
never conditionally return different JSX trees based on typeof window. Explain
why each change prevents the mismatch.
```

## 测试

```bash
# Build and start, then check for hydration errors with curl diff
next build 2>&1 | grep -i "hydrat\|mismatch" && echo "FAIL: hydration error in build" || echo "Build OK"

# Runtime: open browser console after next start and look for hydration warnings
next start &
sleep 3
curl -s http://localhost:3000 | grep -i "data-nextjs-scroll-focus-boundary" > /dev/null && echo "Server rendered OK"
```