docs: root-cause why the compiler rejects Home, and close two dead ends

Diagnosis was unblocked by temporarily installing
babel-plugin-react-compiler and reading its logger events, which the
Rust port swallows. Every failure in Home comes from the compiler, not
from this repository's code: 24 errors under the stable 1.0.0, all
prefixed Todo: (the compiler's own marker for unimplemented syntax) --
13 for try/finally, 9 for throw inside try/catch, 2 for a
non-reorderable MemberExpression. All 13 finally blocks do real cleanup
(clearing a timeout, releasing in-flight guards, resetting loading
flags), so deleting them to please the compiler would trade correctness
for speculative memoization.

Two paths are now closed by measurement rather than assumption. Newer
compiler builds fix the try/finally gap but Home then hits two
consecutive internal Invariant panics, which are compiler bugs. And
project-wide the version makes no difference at all: across 373 files
both versions compile exactly 134 functions, with the same 21 files
failing, so switching implementations buys nothing.

The diagnostic dependency is therefore removed and the lockfile
restored via npm ci. Deliverable is unchanged: config still rolled
back, frontend/src untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 16:13:47 +08:00
parent f78ad09fcc
commit 89d37b6506
3 changed files with 68 additions and 4 deletions
+2 -2
View File
@@ -3839,10 +3839,10 @@
- 最近更新:2026-08-17
- 影响面:`/` 主对话页 `frontend/src/app/page.tsx` 的重渲染性能,以及后续任何「靠 React Compiler 免除手写记忆化」的计划。
- 用户现象:无终端用户可见现象。对维护者而言的现象是:`next.config.ts``reactCompiler: true` 开启后构建打印 `✓ turbopackRustReactCompiler`、退出码 0、测试全绿,**看起来完全成功**,但 `Home` 一个函数都没被优化。这个失败不产生任何警告、错误或日志,只看构建输出无法察觉。
- 根因:`page.tsx` 有 3738 行,其中 `export default function Home()` 单个函数占 2730 行(10023738),带 24 个 `useState`、18 个 `useEffect`、0 个手写 `useCallback`/`useMemo`。Next 16.3.1 的 Rust 版 React Compiler 会正常编译同一文件里的其他函数,却拒编 `Home`:默认 `infer` 模式下全项目 44 个函数拿到缓存槽(`app-sidebar` 112 槽、`use-birth-time-guided-journey` 84 槽、`sidebar-session-row` 73 槽等),`page.tsx` 内部两个小组件(原始行 760、815)也拿到了 11 和 24 槽,唯独 `Home` 的生成代码仍以裸 `useState` 序列开头、没有 `_c(N)` 前导。把 `compilationMode` 设为 `all`(绕过组件识别启发式、强制编译每个函数)后 `page.tsx` 被编译函数从 2 涨到 47`Home` 依然不在其中——既然 `all` 模式下不存在「未被识别为组件」,只剩一种解释:编译器尝试了 `Home` 并失败,然后按 `panicThreshold` 默认值 `none`(官方文档原话 "skips components which cannot be compiled")静默跳过。具体`Home` 哪一处代码触发失败,本轮**未能定位**:`panicThreshold: "all_errors"` 在 Rust 版下形同虚设,设了也不报错、不打印任何编译器诊断
- 根因:`page.tsx` 有 3738 行,其中 `export default function Home()` 单个函数占 2730 行(10023738),带 24 个 `useState`、18 个 `useEffect`、0 个手写 `useCallback`/`useMemo`。Next 16.3.1 的 Rust 版 React Compiler 会正常编译同一文件里的其他函数,却拒编 `Home`:默认 `infer` 模式下全项目 44 个函数拿到缓存槽(`app-sidebar` 112 槽、`use-birth-time-guided-journey` 84 槽、`sidebar-session-row` 73 槽等),`page.tsx` 内部两个小组件(原始行 760、815)也拿到了 11 和 24 槽,唯独 `Home` 的生成代码仍以裸 `useState` 序列开头、没有 `_c(N)` 前导。把 `compilationMode` 设为 `all`(绕过组件识别启发式、强制编译每个函数)后 `page.tsx` 被编译函数从 2 涨到 47`Home` 依然不在其中——既然 `all` 模式下不存在「未被识别为组件」,只剩一种解释:编译器尝试了 `Home` 并失败,然后按 `panicThreshold` 默认值 `none`(官方文档原话 "skips components which cannot be compiled")静默跳过。具体原因经授权后已定位(`panicThreshold: "all_errors"` 在 Rust 版下形同虚设,设了也不报错,因此改用临时安装 `babel-plugin-react-compiler` 并挂 `logger` 钩子的方式取诊断,诊断完已卸载):**`Home` 的失败全部来自编译器自身未实现的语法与内部断言失败,没有一条是本仓库代码写错。** 稳定版 `babel-plugin-react-compiler@1.0.0``Home` 报 24 条错误,去重后三类,全部带 `Todo:` 前缀(React Compiler 用 `Todo:` 标记「该语法尚未实现」):13 条 `Todo: (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause`、9 条 `Todo: (BuildHIR::lowerStatement) Support ThrowStatement inside of try/catch`、2 条 `Todo: (BuildHIR::node.lowerReorderableExpression) Expression type MemberExpression cannot be safely reordered`。即编译器当时还不支持 `try/finally``try/catch` 内的 `throw`,而 `Home` 有 13 个 `finally` 和 9 个这样的 `throw`。这 13 个 `finally` 做的全是 `finally` 该做的事——`window.clearTimeout(bootstrapTimeout)``polling = false``rectificationOpenInFlight.current = false``cancellationRequests.current.delete(requestId)`,以及 8 处 `setProfileSaving(false)` / `setAvatarSaving(false)` / `setCreatingSession(false)` / `setBirthTimeAssessmentPhase(null)` 之类的加载态复位;删掉任何一个,try 块抛错时 UI 就永久卡在加载态,是拿真 bug 换假优化。另在更新的 `0.0.0-experimental-a1856f3-20260507` 上复测:那 22 条 `try/finally``throw` 错误已被上游修好,但 `Home` 随即撞上编译器内部断言失败 `Invariant: Expected all references to a variable to be consistently local or context references``page.tsx:2666``catch (error)` 的绑定同时被直接使用和被 `setRequestError((current) => ...)` 的闭包捕获);在仓库外的副本上把这一处改掉后,又冒出下一个 `Invariant: [PruneHoistedContexts] Unexpected hoisted function``page.tsx:1837``refreshAccount`,被 1626 行的 `useEffect` 提前引用)。`Invariant:` 在 React Compiler 的分类里是编译器 bug 而非用户代码违规。`Home` 共 50 个函数声明,其中 6 个被声明前引用(`refreshAccount` 1837/1626、`editDeclaredBirthTimeDetails` 2280/1098、`completeGuidedBirthTime` 2308/1097、`openRectificationFromHomepage` 2515/2373、`openRectificationSession` 2519/1982、`handleRectificationProfileIncomplete` 2531/2457),要满足编译器就得在 2700 行的组件里跨千行重排这 6 个定义,且照上述规律修完还会有下一个内部 panic。诊断顺带交叉验证了产物取证的正确性:Babel 版报告成功编译的两个函数是 `BirthLocationFields @ 760``ProfileFields @ 815`,与先前从 Rust 版构建产物 source map 反查出的两个缓存槽(行 760/815,槽 11/24)逐一对上,两条独立证据互相印证
- 修复:未修复。`next.config.ts``reactCompiler: true``experimental.turbopackRustReactCompiler: true` 已回滚到与 `origin/staging` 逐字节一致;Next 16.2.10 → 16.3.1 的升级保留(该升级本身独立验证通过,是 Rust 版编译器的前置条件)。本轮共试六种配置全部失败:`reactCompiler: true`、加 `panicThreshold: "all_errors"`、再加文件顶部 `"use memo"``compilationMode: "all"``"all"` + `all_errors``compilationMode: "annotation"` + `Home` 体内 `"use memo"`。其中 `compilationMode: "all"` 还会直接把构建搞坏:它会编译模块作用域的普通回调,`birth-time-intake.tsx:22``Array.from({length:24}, (_, index) => ...)` 被插入 `useMemoCache`,预渲染 `/` 时抛 `TypeError: Cannot read properties of null (reading 'useMemoCache')`。注解模式(领导指定的退路)反而最差:全项目 0 个缓存槽,连原本能编的 44 个也停了。
- 验证:`npx tsc --noEmit` 无输出;`npx eslint` 0 error4 个既有 warning 在未改动文件里);`npx next build` 退出码 0;非数据库套件 199 个文件 1592 条全绿、fail 0 skipped 0 todo 0(与开工基线一致)。取证方式:`Home` 在产物里被压缩改名,`function Home(` 搜不到,改用 source map 反查——解 `.next/server/chunks/ssr/*.js.map` 的 VLQ mappings,把每个缓存槽 `_c(N)`(压缩后真实形态是 `(0,X.c)(N)`)归属回原始文件与行号。反向验证:只加/删 `page.tsx` 顶部一行 `"use no memo"``page.tsx` 被编译函数数在 2 与 0 之间可见切换,且切换只影响 `page.tsx`、其他文件槽数不变,证明取证方法不是恒为真;两种状态下都判定 `Home` 未编译。构建代价(各测 4 次取最快,同机噪声大):开启前 36.65s / 关闭后 35.11s,差异落在噪声内,Rust 版没有可测量的构建变慢;`/` 首屏 JS gzip 470.1 KB → 481.9 KB+12031 B / +2.50%,低于 5% 阈值。
- 待跟进:其一,定位 `Home` 里究竟哪一处挡住编译器,需要能出诊断的编译器;Rust 版不出诊断,Babel 版(`turbopackRustReactCompiler: false`)能出,但要新增依赖 `babel-plugin-react-compiler`next 的可选 peer,未装;Next 只内置了 compiler runtime,没内置这个 plugin),需授权。其二,是否为了那 44 个能编的组件而保留 `reactCompiler: true`:它们大多在 `/` 上渲染(侧边栏、会话行、日期选择器、三个引导 hook),收益真实但本轮未测量,代价是首屏 +2.5%;本轮按止损条款选择回滚,这个取舍留给决策。其三,若要让 `Home` 真正受益,最可能的路是把它拆成若干个小组件——但那是业务代码重构,本轮明令禁止碰 `frontend/src/**`
- 待跟进:其一(已完成,结论见根因):定位 `Home` 的失败原因已获授权并完成,答案是上游编译器缺陷,不是本仓库代码问题。同时用同一套诊断量化了「换编译器版本能否提高覆盖率」这个问题,答案是**不能**:对 `frontend/src` 全部 373 个文件跑批量诊断,稳定版 1.0.0 与 experimental 版**编译成功的函数数完全相同,都是 134 个**,失败文件也都是同样的 21 个,只是报错事件从 74 降到 48——被上游修掉的那些错误类别,所在函数都还有别的拦路错误,所以一个函数都没多编译出来。因此换 Babel 版或换更新版本都买不到任何东西,`babel-plugin-react-compiler` 诊断完即卸载(`npm ci` 从锁文件权威还原,`git diff` 为空),不进交付。复现诊断的方法:临时 `npm i -D babel-plugin-react-compiler`,用 `@babel/core``transformSync``parserOpts.plugins = ["jsx", ["typescript", {isTSX:true}]]` 单独跑该文件,给插件传 `logger: { logEvent(file, event) {} }``event.kind``CompileError` 的即为 bailout`event.detail.message` 是原因。其二,是否为了那 44 个能编的组件而保留 `reactCompiler: true`:它们大多在 `/` 上渲染(侧边栏、会话行、日期选择器、三个引导 hook),收益真实但本轮未测量,代价是首屏 +2.5%;本轮按止损条款选择回滚,这个取舍留给决策。其三,若要让 `Home` 真正受益,最可能的路是把它拆成若干个小组件——但那是业务代码重构,本轮明令禁止碰 `frontend/src/**`诊断结果还把这条路的性质说清了:为迎合编译器去改 `Home`(删 `finally`、重排 6 个提升函数、逐个绕内部断言)是被编译器 bug 牵着走的打地鼠,每一步都拿确定的正确性换不确定的记忆化收益,不该做;真正值得做的是按职责把 `Home` 拆小,那样每个小组件天然落在编译器能处理的范围内,同时也解决可维护性问题。
- 防复发:**「配置开启 + 构建绿」绝不等于「React Compiler 生效」**。编译器放弃某个组件时不报错、不警告、不留日志,这是它的默认行为(`panicThreshold: "none"`)而非缺陷。任何启用 React Compiler 的改动都必须在构建产物里定位目标组件、确认存在编译器注入的缓存槽,并用 `"use no memo"` 做一次反向验证证明取证方法会随编译状态变化;只贴构建退出码等于没验证。另外 `"use memo"` / `"use no memo"` 是函数体内的指令,写在文件顶部时 `"use no memo"` 可作整文件退出、但 `"use memo"` 不构成整文件加入。
- 相关记录:BUG-260 无前序同类记录。本记录三次改号:初次写作取 255;第一次 rebase 到 `origin/staging``c8d9ec64`)时远端已占 254255,改 256;第二次 rebase 到 `e1db5762` 时远端又占到 259,改 260。每次都按 BUG-254 的防复发要求处理——标题与远端各记录均不同,故另分新号而非合并。BUG-253 所记的抢号失效模式在同一轮交付里连续复现两次,暴露出「追加前检索最大编号」这条措施的边界:它只在写记录那一刻成立,防不住推送前远端继续前进。可靠做法是把定号推迟到推送前最后一次 rebase 之后。
- 修复版本:本地未提交候选