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>
19 KiB
PROGRESS · React Compiler 接管 page.tsx 重渲染(codex/react-compiler-20260817)
文件名偏离说明(先写在最前面)
任务书要求新建 PROGRESS.md。本仓库根目录已有受版本控制的 progress.md(1025 行),
而本机文件系统大小写不敏感(已实测:ls PROGRESS.md 命中 progress.md,内容相同)。
在根目录写 PROGRESS.md 等于覆盖 progress.md,而 progress.md 不在「只允许改」清单里。
两条约束打架时,优先不破坏清单外的文件,因此进度记录落在
PROGRESS-react-compiler-20260817.md。BLOCKED.md 无此冲突,按原样追加到根目录既有文件。
我理解的目标/顺序/最大风险(任务 0 要求,≤10 行)
- 目标:让 React Compiler(Rust 版)真正编译
frontend/src/app/page.tsx的Home, 使这个 3738 行、24 个 useState / 18 个 useEffect、0 个手写记忆化的组件由编译器自动优化。 - 顺序:任务 0 复核基线 → 任务 1 升 Next 16.3.1(Rust 版编译器的前置) → 任务 2 开启配置并取证 +
反向验证 → 任务 3 测构建代价 → 补
docs/BUG_HISTORY.md并提交。 - 让步顺序:编译器真的生效 > 测试与构建全绿 > 构建速度。
- 最大风险:配置开了、构建绿了,但编译器对着这个超大组件静默放弃(bailout 不报错),
于是「看起来做完了、实际零收益」。所以取证必须落在构建产物上,并用
"use no memo"反向验证 证明取证方法不是恒为真。 - 次要风险:16.3 的其他变更牵连别处(按任务 1 止损条款回滚);构建变慢或首屏体积上涨。
- 硬红线:不手写 useCallback/useMemo;
frontend/src/**只读(注解模式的一行指令除外); 测试与 CI 配置一行不碰;测试数只许 ≥1592 且 skipped=0。
任务 0 · 基线复核
worktree:/Users/jesse/Downloads/Copse/astrology/.worktrees/react-compiler-20260817
(git worktree add -b codex/react-compiler-20260817 ../.worktrees/react-compiler-20260817 origin/staging,
基点 e8d201dd)。frontend/ 已跑一次 npm install(新 worktree 无 node_modules)。
| 复核项 | 任务书给的数 | 实测 | 结论 |
|---|---|---|---|
| 过滤后测试文件数 | 199 | 199 | 对上 |
| 测试通过数 | 1592 | tests 1592 / pass 1592 / fail 0 / skipped 0 / todo 0 | 对上 |
npx tsc --noEmit |
干净 | 退出码 0,无输出 | 对上 |
page.tsx 行数 |
3738 | 3738 | 对上 |
| next 当前版本 | 16.2.10 | 16.2.10 | 对上 |
npm view next version |
16.3.1 | 16.3.1 | 对上 |
babel-plugin-react-compiler |
未装 | 未装(Rust 版不需要) | 对上 |
npx react-compiler-healthcheck 查实:包存在,npm view react-compiler-healthcheck version = 1.0.0。
但它是 Babel 版编译器的体检工具,跑的不是本次实际启用的 Rust 版编译管线,
两者的 bailout 判定可能不一致,healthcheck 通过不等于 Turbopack 里真编译了。
因此取证采用任务 2 的第二种方法(构建产物里定位 Home 的 _c(N) 缓存槽),
它直接落在真实构建产物上,且能被 "use no memo" 反向验证推翻。
锁文件:npm install 如任务书预告的那样整体重排 —— 961 条依赖,键序由非规范变为 npm 规范的字典序,
另有 6 条(@types/react-dom、escape-string-regexp、js-tokens、loose-envify、prop-types、
react-is)去掉了 dev: true(npm 重算了它们的 dev 可达性)。逐条比对确认:
包数量 961→961、无新增、无删除、无 version/resolved/integrity 变化。已单独成一个提交
(3371baca),排在版本升级之前,这样升级那一笔的 diff 只有 490 行且逐行可读。
顺带记下 16.2.10 的构建参考点(升级前的退路数据,不是任务 3 的四个数):
npx next build 退出码 0、real 39.02s,/ 首屏 18 个 JS chunk 共 gzip 490798 B = 479.3 KB。
任务书给的 476.3 KB 参考值与此相差 0.6%,说明体积口径对齐了。
任务 1 · 升级到 Next 16.3.1(完成)
npm install next@16.3.1 --save-exact。锁文件的真实变更 490 行,逐条核对为:
next/@next/env/@next/swc-* 16.2.10→16.3.1,加上 npm 自行重解析的传递依赖
sharp 0.34.5→0.35.3、@img/* 一族、@swc/helpers 0.5.15→0.5.23、
postcss 8.4.31→8.5.23(原先嵌在 @tailwindcss/postcss 下的副本被提升合并,故包数 961→962)。
这些都是升级的传递结果,不是我顺手升的依赖;package.json 只动了 next 一行。
eslint-config-next 仍留在 16.2.10:与 next 版本号不同步,但 npx eslint 实测 0 error
(4 个 warning 全在我没碰过的 src/、tests/ 文件里,升级前就有),
没有出现必须同步升级的理由,按「不许顺手升别的依赖」保持不动,只把这条记在这里备查。
验收:
| 项 | 结果 |
|---|---|
npx tsc --noEmit |
退出码 0,无输出 |
npx next build |
退出码 0(两次,均绿) |
| 测试 | tests 1592 / pass 1592 / fail 0 / cancelled 0 / skipped 0 / todo 0 |
测试第一遍出现过 1 个失败:tests/rectification-v9-database.test.ts 的
「v9 migration applies on a fresh database and re-applies idempotently」报
database migration failed(1 !== 0)。它靠 Docker 起临时 Postgres,与 Next 版本无因果关系。
单独重跑该文件 7/7 全过,全量重跑 1592/1592 全过,判定为全量并发下 Docker 资源争用导致的 flake。
没有改任何测试文件,也没有加 skip:两次原始输出都在对话里贴了。
任务 2 · 开启编译器并取证(结论:编译器拒编 Home,按止损条款回滚配置)
配置本身是对的
reactCompiler 确认是顶层选项、turbopackRustReactCompiler 确认在 experimental 里,
依据是 16.3.1 自带的类型定义 node_modules/next/dist/server/config-shared.d.ts:
第 1399 行 reactCompiler?: boolean | ReactCompilerOptions(NextConfig 顶层)、
第 1092 行 turbopackRustReactCompiler?: boolean(ExperimentalConfig 内)。
任务书这一点是对的,网上 experimental.reactCompiler 的写法确实是 Next 15 旧写法。
按此写入后构建打印 ✓ turbopackRustReactCompiler,authInterrupts、optimizePackageImports、
output、turbopack、outputFileTracingRoot、outputFileTracingIncludes 全部逐项保留。
取证方法
用任务书的第二种取证法(构建产物定位 Home + 找编译器注入的缓存槽),不用 healthcheck,理由见任务 0。
实现细节:Home 在产物里被压缩改名,function Home( 搜不到,所以改用 source map 反查 ——
.next/server/chunks/ssr/_0kxuuj2._.js.map 的 sources 里有 frontend/src/app/page.tsx,
解 VLQ mappings 就能把每个生成位置映射回原始文件与行号。两个脚本都放在 /tmp,不进仓库:
/tmp/locate_home.mjs(定位 page.tsx:1002 的 Home 并打印生成代码)、
/tmp/slot_owners.mjs(把每个缓存槽 _c(N) 归属到原始文件,给出「每文件被编译函数数」表)。
缓存槽在压缩后的真实形态是 (0,X.c)(N) 而不是 _c(N),正则要匹配这个形态。
六次尝试的结果(全部原始输出已贴在对话里)
| # | 配置 | page.tsx 被编译函数 |
Home |
构建 |
|---|---|---|---|---|
| 1 | reactCompiler: true |
2 | 未编译 | 退出码 0 |
| 2 | { panicThreshold: "all_errors" } |
2 | 未编译,且未报任何错 | 退出码 0 |
| 3 | 同上 + page.tsx 文件顶部 "use memo" |
2 | 未编译 | 退出码 0 |
| 4 | { compilationMode: "all" } |
47 | 未编译 | 失败 |
| 5 | { compilationMode: "all", panicThreshold: "all_errors" } |
47 | 未编译,且未报任何错 | 失败 |
| 6 | { compilationMode: "annotation", ... } + Home 体内 "use memo" |
0 | 未编译 | 退出码 0 |
关键读数:
- 默认
infer下全项目共 44 个函数拿到缓存槽,page.tsx内部有 2 个小组件被编译 (原始行 760、815,槽数 11 和 24),但 3738 行文件里那个 2730 行的Home没有。 所以不是「文件没进编译器」,是「Home这一个函数被拒」。 - 第 4/5 次把
compilationMode设成all(绕过组件识别启发式,逼编译器编译每个函数),page.tsx被编译函数从 2 涨到 47,Home仍然没有。既然all模式下不存在「没被识别成组件」 这回事,那就只剩一种解释:编译器尝试了Home并失败,然后按panicThreshold默认值none(文档原话「skips components which cannot be compiled」)静默跳过。 panicThreshold: "all_errors"在 Rust 版下形同虚设:第 2、5 次都设了,构建既不报错也不打印 任何编译器诊断。所以拿不到「Home到底哪一行挡住了编译器」的具体原因。- 第 4/5 次的
compilationMode: "all"还会直接把构建搞坏,不能用:它会去编译模块作用域的普通回调, 于是birth-time-intake.tsx:22的Array.from(..., (_, index) => ...)被插入useMemoCache, 预渲染/时炸TypeError: Cannot read properties of null (reading 'useMemoCache')。 - 第 6 次是领导指定的注解模式退路,也不成立。顺带纠正任务书一处技术细节:
"use memo"是函数体内的指令,不是文件顶部指令,依据是 16.3.1 自带文档node_modules/next/dist/docs/.../reactCompiler.md第 82-96 行的示例 (export default function Page() { 'use memo' ... })。我按正确位置放进Home体内第一行, 结果整个项目 0 个缓存槽、Home依旧未编译 —— 注解模式连原本能编的那 44 个都停了,纯亏。
反向验证(证明取证方法不是恒为真)
在 reactCompiler: true 下,只加/删 page.tsx 文件顶部一行 "use no memo",其余不动:
- 加上:
frontend/src/app/page.tsx compiled functions: 0 - 删掉:
frontend/src/app/page.tsx compiled functions: 2(槽 11、24),全项目 44 个槽
两段有可见差异,且差异只落在 page.tsx 上(其他文件的槽数不变),说明这套取证能真实反映
page.tsx 的编译状态,不是恒为真。同时它在两种状态下都判定 Home 未编译,
所以「Home 没被编译」不是取证方法的假象。反向验证用完已删除该行,page.tsx 与 origin/staging 逐字节一致。
结论与处置
任务 2 的验收(拿出 Home 被编译的证据)没做成,而且不是配置写错,是 Next 16.3.1 的 Rust 版
React Compiler 在所有六种配置下都拒编 Home,包括显式 "use memo" opt-in。
把它编译过去只剩两条路,两条都越界,所以都不做、写进 BLOCKED.md:
- 改
page.tsx的业务代码去迎合编译器 —— 任务书明令禁止(「哪处代码挡住了编译器就写进 BLOCKED.md」)。 - 换 Babel 版编译器(
turbopackRustReactCompiler: false)—— 需要新增依赖babel-plugin-react-compiler(next 的可选 peer,未装,Next 只内置了 runtime 没内置 plugin), 撞「不新增依赖」。
按任务 2 止损条款「连败 3 次改注解模式;仍失败就回滚编译器配置、保留任务 1 的升级、如实汇报」,
已把 next.config.ts 回滚到与 origin/staging 逐字节一致(git diff 为空),保留 Next 16.3.1。
用掉 6 轮验收,未满 12 轮;停下的原因不是轮数,而是不越界的配置空间已经穷尽。
任务 3 · 构建代价(四个数)
体积口径与基线一致:汇总 .next/server/app/index.html 引用的全部 /_next/static/**.js,
去重后逐个 gzip(Node zlib.gzipSync 默认级别)再相加。/ 是静态预渲染路由(构建输出标 ○ /),
index.html 存在,共 21 个 JS chunk。测量脚本 /tmp/measure_first_load.mjs,不进仓库。
每次构建前都 rm -rf .next(同时清掉 .next/cache/turbopack),四次都是冷构建。
四个数(任务书要求「各测 2 次取较快一次」,下表按前 2 次取最快给出):
| 编译器关闭 | 编译器开启 | 变化 | |
|---|---|---|---|
next build 耗时 |
41.29 s | 35.11 s | −6.18 s |
/ 首屏 JS(gzip 汇总) |
481397 B = 470.1 KB | 493428 B = 481.9 KB | +12031 B / +2.50 % |
必须说清的一点:耗时这两个数不可信,同机噪声远大于效应。实际观测到的全部冷构建耗时是 关闭 41.29 / 130.27 / 36.65 / 37.26 s,开启 100.43 / 35.11 / 39.21 s —— 机器安静时两边都落在 35~41 s,偶发的 100 s 与 130 s 是外部负载造成的离群值。 所以正确结论不是「开启后构建更快」,而是Rust 版编译器在本仓库没有可测量的构建变慢, 这与官方「Rust 版为缓解构建变慢而做」的说法方向一致。为避免误导,我补测到每边 4 次才敢这么说。 按任务书口径「取较快一次」,前两次的最快值就是上表的 41.29 与 35.11。
体积是确定性的:关闭态 4 次构建都是 481397 B,开启态 2 次都是 493428 B,逐字节可复现。 首屏涨 2.50 %,低于 5 % 阈值,不触发任务书的强制说明条款。
保留还是回滚的建议:本轮已按任务 2 止损条款回滚。若纯看这三个数,
「构建不变慢、首屏 +2.5 %、换 44 个组件自动记忆化」本身是笔划算的交易,
而且那 44 个里的 app-sidebar(112 槽)、sidebar-session-row(73 槽)、birth-date-picker(58 槽)、
三个引导 hook 都在 / 上渲染,收益落在同一个页面上。
但本轮的目标是 Home,Home 一个槽都没拿到;那 44 个组件的实际收益本轮没有测量(没有渲染基准),
在「收益未测量 + 首屏确定变大 + 止损条款明确要求回滚」三者叠加下,我选择回滚,
把「是否为了这 44 个组件而保留 reactCompiler: true」作为一个独立决策交出去,
而不是顺手替决策者拍板。要重新开启只需在 frontend/next.config.ts 加回两行,
本文件的第 1/6 次尝试记录了确切写法。
该决策已有结论(2026-08-17,交付后确认):维持回滚。 理由是本轮目标是 Home,
而那 44 个组件的运行时收益没有测量数据支撑首屏 +2.50 % 的代价。
若日后要改变这个结论,前置条件应当是先给那 44 个组件测出渲染基准,而不是直接开配置。
追加轮 · 授权新增依赖后的根因诊断(2026-08-17)
用户授权新增依赖,于是把 BLOCKED.md 第一条(拿不到编译器诊断)解掉。做法:临时
npm i -D babel-plugin-react-compiler,用 @babel/core 的 transformSync 单独跑 page.tsx
(parserOpts.plugins = ["jsx", ["typescript", {isTSX:true}]]),给插件传 logger 钩子逐函数收事件。
脚本在 /tmp/rc_diagnose.mjs、/tmp/rc_batch.mjs,不进仓库。
Home 到底被什么挡住
稳定版 1.0.0 对 page.tsx 报 26 个事件:CompileSuccess 2、CompileError 24。
Home(行 1002)的 24 条去重后三类,全部带 Todo: 前缀 —— 这是 React Compiler
标记「该语法尚未实现」的前缀,不是用户代码违规:
| 条数 | 原因 |
|---|---|
| 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 |
那 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 换假优化,所以一行没动。
顺带交叉验证了之前的产物取证:Babel 版报告编译成功的是 BirthLocationFields @ 760 与
ProfileFields @ 815,和先前从 Rust 版构建产物 source map 反查出的槽 11/24(行 760/815)
逐一对上。两条完全独立的证据链互相印证,取证方法没问题。
更新版本能不能救
experimental 版(0.0.0-experimental-a1856f3-20260507)里那 22 条 try/finally 与 throw
错误已被上游修好,CompileError 从 24 降到 2。但 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 个被声明前引用,要满足编译器得跨千行重排这 6 个定义。
一句话:打地鼠,且每步都由编译器 bug 而非代码质量驱动。
换版本/换实现的全项目收益:零
对 frontend/src 全部 373 个文件跑批量诊断,两版对比:
| 稳定版 1.0.0 | experimental 版 | |
|---|---|---|
| 编译成功的函数数 | 134 | 134 |
| bailout 事件数 | 74 | 48 |
| 有 bailout 的文件数 | 21 | 21 |
成功数一个没多。原因是被上游修掉的错误类别,所在函数都还有别的拦路错误。
所以「升编译器版本」或「改用 Babel 版实现」这两条路由数据关闭,
babel-plugin-react-compiler 诊断完即卸载,锁文件用 npm ci 权威还原(npm uninstall 会留下
3 条 dev → devOptional 的元数据漂移,已还原掉),git diff 为空,不进交付。
这一轮改变了什么结论
任务 2 的验收结论不变(Home 未被编译),但原因的性质变了:
从「不明原因的静默失败」变成「上游编译器缺陷,本仓库代码无错」。
这直接影响后续该怎么做:不该去改 page.tsx 迎合编译器,该做的是按职责拆小 Home。
交付物本身零变化 —— 配置仍是回滚态,frontend/src/** 仍是零改动。
收尾
docs/BUG_HISTORY.md 追加 BUG-255。按 BUG-253 的防复发要求先检索最大编号:本地基点
e8d201dd 的最大编号是 253,但期间 origin/staging 已推进到 fb9eec54 并占用了 254
(标题「22 条记录的正文挂在别人的标题下…」),所以顺延为 255 —— BUG-253 记录的抢号失效模式又发生了一次,
已在记录里注明。提交前 npx tsc --noEmit 无输出、npx eslint 0 error(4 个既有 warning)。
交付内容(相对 merge-base e8d201dd):frontend/package.json(next 一行)、
frontend/package-lock.json(两笔:规范化重排 + 升级)、docs/BUG_HISTORY.md(+15 行)、
BLOCKED.md(+10 行)、本文件(新建)。
frontend/next.config.ts 已回滚、frontend/src/**、frontend/tests/**、tests/**、CI 配置零改动,
git diff 逐项为空。最终测试 tests 1592 / pass 1592 / fail 0 / cancelled 0 / skipped 0 / todo 0。