## [ERR-20260424-001] 生产环境 React error #310 — 钉钉年假 quotas 页 hooks 规则违反

**日期**: 2026-04-24
**类别**: Frontend / React Hooks Rules
**严重度**: 高（生产白屏，用户不可用）

### 问题描述

生产环境 `ffworkspace.faradayfuture.com` 访问钉钉年假余额总览页时，页面先显示 spinner，数据返回后立刻整页白屏。浏览器 console 报：

```
Uncaught Error: Minified React error #310
  at useMemo
  at j (c908fd8a33c3dc1d.js:1:26976)
```

### 根本原因

`frontend/src/app/(modules)/sync-center/dingtalk/annual-leave/quotas/page.tsx` 里，组件体内顺序是：

1. `useTranslation` / 10× `useState` / `useEffect` / `useCallback`
2. `if (loading) return <spinner>;`  ← 提前返回
3. 3× `useMemo`（`fullMatrix` / `mismatchCount` / `statusMap`）

首次渲染 `loading=true` 走提前返回，`useMemo` 没被调用；数据返回后 `loading=false` 不再返回，3 个 `useMemo` 被调用 → 两次渲染 hook 数量不一致 → React error #310 "Rendered more hooks than during the previous render"。

### 不直观的点

- 服务端 PM2 日志没有任何 #310 相关输出。**客户端运行时异常不会出现在 Next.js 服务端日志里**（除非接了 Sentry 类的上报）。
- 生产构建没带 source map（.js.map 不存在），栈里只能看到单字母函数名。
- 需要直接从生产 `.next/static/chunks/` 下载 minified chunk，再根据偏移量定位 `j` 函数体，结合源码 grep API 名/locale key（本例：`getDingtalkAnnualLeaveQuotas` / `dingtalkAnnualLeave`）反查源文件。

### 修复方案

把 3 个 `useMemo` 移到 `if (loading) return` **之前**。它们的 deps (`data?.items`, `data?.allLeaveTypes` 等) 天然允许 null/undefined，移动后无副作用。

### 预防

- **所有条件返回必须放在所有 Hook 之后**，这是 Rules of Hooks 的基本约束。
- CI 应启用 `eslint-plugin-react-hooks` 的 `react-hooks/rules-of-hooks` 规则为 error 级别（本项目已有但构建未阻断，值得查一下）。
- 生产构建打开 `productionBrowserSourceMaps: true`（或在 Nginx 限流下开放）可以大幅加速此类排查，代价是源码风险；折中方案是 Sentry + 上传 source map。

### Metadata
- Reproducible: yes（访问 `/sync-center/dingtalk/annual-leave/quotas` 且数据 load 完成）
- Related Files: frontend/src/app/(modules)/sync-center/dingtalk/annual-leave/quotas/page.tsx
- Related Skill: `.agents/skills/prod-error-triage/SKILL.md`（本次同步新增）

---
