From bf697d71a2b22eaf5b921f2f2a57408fb935a868 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 28 Aug 2026 18:38:53 +0000 Subject: [PATCH] docs(frontend): task book for the nine-item web optimization pass Derived from a static audit of frontend/ and web/ at staging 87f32d59. The audit ran without node_modules, so no build, eslint, or tsc was executed; every size and timing claim is inferred and task 0 requires the executing agent to re-measure before acting on it. Sequencing follows the repository's own history. The React Compiler route stays closed per PROGRESS-react-compiler-20260817.md and BLOCKED.md: Home fails on upstream compiler Todo/Invariant errors, not on repository code, and neither compiler version raised the project-wide success count above 134. A render benchmark gates the two performance tasks, because the same missing benchmark already blocks the 44-component decision recorded in BLOCKED.md. Dark mode is left out as a product decision rather than an agent task. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8 --- TASK-frontend-optimization-20260828.md | 305 +++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 TASK-frontend-optimization-20260828.md diff --git a/TASK-frontend-optimization-20260828.md b/TASK-frontend-optimization-20260828.md new file mode 100644 index 00000000..49c58386 --- /dev/null +++ b/TASK-frontend-optimization-20260828.md @@ -0,0 +1,305 @@ +# TASK · 前端优化九条(codex/frontend-optimization-20260828) + +## 来源与前提 + +本任务书来自一次前端静态审计,范围 `frontend/` 与 `web/`,基线 `staging @ 87f32d59`。 + +**审计方自己的边界(必须先知道):** 审计时 `frontend/node_modules` 是空的,**没有跑过 `next build` / `eslint` / `tsc`**。 +下面所有数字都来自源码静态检索、可复核,但凡涉及**产物体积**与**运行时耗时**的结论都是推断。 +**任务 0 的第一件事就是复核这些数字,对不上以你实测的为准,并在进度文档里写明差异**——不要拿着一份没验证过的清单开工。 + +--- + +## 硬红线 + +1. **不得手写 `useCallback` / `useMemo` 去凑性能。** 本轮的手段是**拆组件 + `React.memo` 边界**。 + 理由见 `PROGRESS-react-compiler-20260817.md`:手写记忆化在这个仓库里已经被明确排除, + 拆分才是同时解决重渲染和解锁编译器的路径。 +2. **不得为了迎合 React Compiler 去改 `page.tsx` 的写法。** 那条路已经走到底并证伪(见下方"已关闭的路")。 +3. **不得在未跑 `npx tsc --noEmit` 的情况下推 `staging`。** 这是 BUG-409 的防复发条款: + validate 阶段跳过 `next build`,类型错误要到 publish 才炸,公网会停在上一个 SHA。`tsx --test` 全绿不能替代。 +4. **测试数只许 ≥ 任务 0 实测的基线,且 `skipped=0`、`fail=0`。** 不得删测试、不得 skip 测试来换绿。 +5. **不得改 CI 配置**(`.gitea/`、`.github/`)。 +6. **不得动 `frontend/tests/**` 与 `tests/**` 里的既有断言**;新增测试可以,改既有断言不行——除非你能证明该断言锁的是错误行为,并在进度文档里单独论证。 +7. **不得在存在未提交修改的工作树上切分支 / stash / reset / 顺带提交用户变更。** + +## 让步顺序 + +功能与测试不回归 **>** 可被验证的性能收益 **>** 代码整洁度 **>** 构建速度。 + +任何一步做不到时,**写进 `BLOCKED.md` 并停在那一步**,不要绕路硬做。本仓库已经有过一次 +"配置开了、构建绿了、实际零收益"的教训,宁可少交付也不要交付看起来完成的东西。 + +## 已关闭的路(别重跑) + +`PROGRESS-react-compiler-20260817.md` 与 `BLOCKED.md` 记录了完整调查: +`Home` 无法被 React Compiler 编译,失败全部来自上游编译器的 `Todo:`(未实现语法)与 `Invariant:`(编译器内部断言,即编译器 bug), +**本仓库代码没有写错**。稳定版 1.0.0 与 experimental 版的全项目编译成功数都是 **134**,一个没多。 +所以:**不要再去升编译器版本、不要再去装 `babel-plugin-react-compiler` 做诊断、不要再开 `reactCompiler: true` 试。** +那份文档给出的下一步就是本任务书的任务 4。 + +--- + +## 开工前置 + +按 `AGENTS.md` 第 5、6、7 节执行,不可省略: + +```bash +git fetch origin --prune +git worktree add -b codex/frontend-optimization-20260828 \ + ../.worktrees/frontend-optimization-20260828 origin/staging +``` + +- 基线必须是 **`origin/staging`**,不得基于本地 `staging` 或本地 `main`。 +- 读 `docs/research/pre_work_error_ledger.md`。 +- 跑 `python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`。 +- **读 `frontend/AGENTS.md`**:这个 Next.js 版本与你训练数据里的不一样, + 写任何 Next.js 代码前先读 `frontend/node_modules/next/dist/docs/` 里的对应指南。任务 3 尤其吃这一条。 +- 本任务书涉及"重渲染、样式加载、字体不生效"等现象,按第 7 节先检索 `docs/BUG_HISTORY.md` + (关键词建议:`重渲染`、`force-dynamic`、`globals.css`、`字体`、`localStorage`、`chartLibrary`), + 命中记录的防复发措施逐条确认是否仍然存在。 + +--- + +## 任务 0 · 基线复核(必做,不产出代码) + +`npm install`(worktree 是新的,没有 `node_modules`),然后逐项实测并填表: + +| 复核项 | 审计给的数 | 你的实测 | 结论 | +| --- | --- | --- | --- | +| `Home` 起止行 | `page.tsx:1090–4313` | | | +| `Home` 行数 | 3,224 | | | +| `Home` 内 `useState` | 61 | | | +| `Home` 内 `useEffect` | 19 | | | +| `Home` 内 `useMemo` / `useCallback` | 0 / 0 | | | +| 全仓库 `React.memo` | 0 | | | +| `globals.css` 行数 / 选择器数 | 3,278 / 1,389 | | | +| 字体文件数 / `@font-face` / `next/font` | 0 / 0 / 0 | | | +| `public/` 未引用 SVG | 4 | | | +| `jyotish-logo.png` 尺寸 / 体积 | 128×125 / 33,511 B | | | +| `frontend/tests` 测试文件数 | 260 | | | +| `tsx --test` 测试数 / 通过数 | 待实测 | | | +| `npx tsc --noEmit` | 待实测 | | | +| `npx next build` 退出码 / 耗时 | 待实测 | | | +| `/` 首屏 JS chunk 数 / gzip 体积 | 待实测 | | | + +最后四行是**任务 2、5 的收益对照基准**,必须记下来,否则后面无法证明改动有收益。 + +--- + +## 任务 1 · 建渲染基准(**门控任务,不通过则任务 3、4 不许开始**) + +**这是本轮最重要的一步。** 审计报告第 01、02 条的因果链是从代码结构推出来的,逻辑成立, +但"流式回复期间每个 token 重算多少毫秒"**没有实测过**。`BLOCKED.md` 里那 44 个组件的收益 +也正是因为缺渲染基准而无法拍板——**同一个缺口卡住了三处决策,补一次能全解开。** + +要求: + +1. 建立一个可重复的渲染基准,测量**流式回复期间 `Home` 的重渲染次数与累计渲染耗时**。 + 手段自选(React Profiler API / `react-scan` / 在 `Home` 里挂一个仅开发期生效的计数器均可), + 但必须满足:**同一份输入跑两次,结果偏差在可接受范围内**,否则它证明不了任何事。 +2. 场景至少两个: + - **空会话**:新建会话 + 一条约 800 字的流式回复。 + - **长会话**:≥ 40 条历史消息 + 一条约 800 字的流式回复。 + 长会话是第 02 条 `O(消息数) × 每 token` 的判定场景,缺了它任务 3 就没有验收依据。 +3. 基准脚本与原始读数落到 `frontend/tests/` 或 `frontend/scripts/` 下, + 并在进度文档里贴出**改动前的读数**。 + +**止损条款:** 如果两次运行的偏差大到无法区分 20% 的改进, +说明基准不可用——**把它写进 `BLOCKED.md` 并停止任务 3、4**,只交付任务 2 和任务 5。 +不要在没有基准的情况下做性能改动然后声称有收益,这正是上一轮踩过的坑。 + +--- + +## 任务 2 · 低风险清理批次(可独立交付) + +五条互不相关的机械修复,一个提交批次。**每条都要单独验证,不要打包声称"都改好了"。** + +### 2.1 删除根布局的 `force-dynamic` + +`frontend/src/app/layout.tsx:8` 的 `export const dynamic = "force-dynamic"` 是多余的: +`/login`、`/admin`、`/reports`、`/reports/[reportId]` **各自都已经声明过一遍**, +根部这行只是让整棵路由树永久失去静态优化与路由级缓存。 + +- 根布局本身不读 `cookies()` / `headers()`(已核实),`page.tsx` 是 `"use client"` 且数据全在客户端拉。 +- **但这条必须实测验证,不能想当然**:改动后跑 `npx next build`, + 逐条比对每个路由的渲染模式(`○ Static` / `ƒ Dynamic`), + 确认**没有任何一个本该动态的路由被静态化**。尤其检查 `/`: + `next.config.ts` 已对 `/` 和 `/login` 设了 `private, no-store`,但静态化后的行为要亲自确认。 +- **止损条款:** 只要有一个路由的渲染模式变化说不清,**还原这一条**,其余四条照常交付。 + +### 2.2 `Inter` 声明了却从未加载 + +`frontend/src/app/globals.css:92` 与 `frontend/src/components/admin/admin-app.tsx:54` 都把 `Inter` +写进字体栈,但仓库里 0 个字体文件、0 处 `@font-face`、0 处 `next/font`—— +Windows / Linux 用户一直静默落到 Segoe UI / 微软雅黑。 + +**注意区分:** `Tiempos Headline` 的缺席是**有据的取舍**,`DESIGN.md` 第 3 节写明了授权文件不可用、 +宋体栈是声明的生产替代。**不要动它。** 只处理 `Inter`。 + +二选一,不许停在中间: + +- **(a)** 用 `next/font/google` 真的加载 `Inter`(顺带拿到 preload 与 `font-display: swap`), + 两处引用都要覆盖; +- **(b)** 从两处字体栈里删掉 `Inter`,让声明与渲染结果一致。 + +选哪个都要**在 `DESIGN.md` 第 3 节记一行**,说明这是一个决定而不是遗漏。 +选 (a) 时必须实测确认字体确实被加载(Network 面板或构建产物里能看到 woff2), +否则等于把一个静默回退换成另一个。 + +### 2.3 删除 `web/` 下三个无人引用的 HTML + +`web/index.html`、`web/rectification.html`、`web/evidence_packet.html` +全仓库检索不到引用,`mcp_server.py` 也没有挂载静态目录。它们把 API 响应直接 +`JSON.stringify` 打印给用户,主色 `#006b6b` 与现行设计系统无关,是更早的原型残留。 + +- **删除前必须自己再检索一遍**(含 `deploy/`、`.gitea/`、`Dockerfile`、`docs/`),确认真的没有引用。 +- 若确实还当调试夹具用,**移到 `scripts/` 下并在文件头写明用途**,不要留在 `web/` 冒充产品界面。 + +### 2.4 清理 `public/` 脚手架资源与 logo 体积 + +- 删除 `vercel.svg`、`window.svg`、`file.svg`、`globe.svg`(`create-next-app` 默认资源,无引用)。 + `next.svg` 的检索命中疑似误报(`grep` 的 `.` 通配),**删前逐个确认**。 +- `jyotish-logo.png` 实际 **128×125 却有 33,511 B**,登录页以 32px 显示。 + 同尺寸 PNG 合理体积在 4–6 KB。换 SVG,或按 2× 需求重导出并压缩。 + 换 SVG 时注意 `email-otp-login.tsx:285` 用的是 `next/image` 且 `alt=""`(装饰性,`DESIGN.md` 第 81 行有据),保持不变。 + +### 2.5 合并两个重叠的 effect + +`frontend/src/app/page.tsx:1302` 与 `page.tsx:1338` 依赖数组都是 `[accountId, profile]`, +都调用 `upsertSelfChart` 并写 `chartLibraryStorageKey`。 +靠 `chartLibraryLoadedAccount` ref 提前 return 来分工,逻辑能跑通但没有注释。 +代价是每次 `profile` 引用变化都在主线程同步做一次 `JSON.stringify` + `localStorage.setItem`。 + +- 合并为一个 effect,用**显式分支**表达"首次加载云端"与"本地 upsert"两条路径。 +- **行为必须完全等价**:云端拉取失败时本地库仍可用、`self` 记录仍被 upsert、 + 切换账号时仍清空。这三条各补一个针对性测试。 +- 这块会随任务 4 的拆分一起被搬走,现在做掉能让后面那一刀更干净。 + +**任务 2 验收:** `npx tsc --noEmit` 无输出、`npx eslint` 无 error、 +测试数 ≥ 基线且 `skipped=0`、`npx next build` 退出码 0。 + +--- + +## 任务 3 · 消息列表记忆化(依赖任务 1 通过) + +`frontend/src/app/page.tsx:4010` 在 JSX 里直接调用 +`chatMessageViews(activeSession.messages, …).map(…)`。 +结合 `Home` 的零记忆化,长会话下这是 **O(消息数) × 每个 token** 的整表重建。 +`renderKey` 用得是对的,React diff 会剪掉大部分 DOM 写入,但 `chatMessageViews` +本身的计算与数组分配躲不掉。 + +要求: + +1. 把消息列表提成独立组件并用 `React.memo` 包住。 +2. **把"已完成的历史消息"与"正在流式的最后一条"拆成两个渲染单元**—— + 只有后者需要跟着 token 走。这是本条的核心,只加 `memo` 不拆是无效的。 +3. 注意 `chatMessageViews` 的入参里有 `isLoading`、`activeStreamingText` 等会随 token 变的值, + 拆分时要保证**历史消息那一半的 props 在流式期间引用稳定**,否则 `memo` 形同虚设。 + +**验收取证(必须可被推翻):** 用任务 1 的长会话场景,给出改动前后的 +**重渲染次数与累计渲染耗时**两组读数。并做一次**反向验证**: +临时去掉 `memo`,确认读数退回改动前水平—— +这一步是为了证明你的取证方法不是恒为真。上一轮的教训就在这里。 + +**止损条款:** 若长会话场景下改进不足 20%,说明瓶颈不在这里, +**如实写进进度文档,还原改动**,不要为了交付而保留一个没有收益的重构。 + +--- + +## 任务 4 · 拆 `Home` 第一刀(依赖任务 1 通过;**只切一块**) + +`page.tsx:1090–4313` 是一个 3,224 行的客户端组件,61 个 `useState`、19 个 `useEffect`、 +0 处记忆化。流式回复期间每个 token 都让整个函数体重跑一遍, +包括 `page.tsx:1200` 那串 `sessions.filter().filter().sort()`, +以及侧边栏、会话列表、输入框、全部弹层的重渲染。 + +**本轮只切耦合最低的一块,不要试图拆完。** 建议优先级: + +1. **账户 / 资料弹层**(耦合最低,state 集中,UI 边界清晰) +2. 图库 + 合盘 +3. 生时校正外壳(耦合最高,涉及 `resumeRectificationSession` ref 与多个 effect,**本轮不要碰**) + +要求: + +- 切出去的子树用 `React.memo` 封边界,props 引用保持稳定。 +- **`Home` 的 `useState` 数量必须实际下降**,并在进度文档里给出前后数字。 + 如果拆完 state 还留在 `Home` 里靠 props 往下传,那这一刀没有意义。 +- 不得改变任何用户可见行为。`DESIGN.md` 里该组件的无障碍契约 + (44px 触达、`focus-visible`、live region、reduced-motion、forced-colors)**逐条保留**—— + 这些在拆组件时最容易被顺手丢掉,交付前对着 `DESIGN.md` 第 4 节复核一遍。 + +**验收取证:** 同任务 3,给出改动前后读数 + 反向验证。 + +**止损条款(硬):** 单块拆分超出 **400 行改动**或触碰到第三类(生时校正外壳)时, +**立刻停下并把已完成的部分交付**,剩余写进 `BLOCKED.md`。 +这个组件已经有一轮失败史,宁可切一小块并证明收益,也不要开一个收不了口的大重构。 + +--- + +## 任务 5 · 隔离 admin 的样式负担 + +`globals.css` 3,278 行、1,389 个选择器、25 处 `!important`,由根布局引入,每条路由都拿全量。 +`/admin` 最冤:它用 antd 组件树,**另外还要再加载 antd reset 与 antd 本体**, +而这 3,278 行聊天 / 生时校正 / 会员页样式它几乎一条都用不上。 + +**本轮只做最小动作:** 把 admin 从根布局的 `globals.css` 里摘出去(给它一条自己的布局链)。 +按现有的 8 个注释分区切成 shell / chat / rectification / membership 四个入口是**下一轮的事,本轮不做**。 + +- 摘出去后**必须逐页目视验证 admin 各路由没有样式塌陷** + (`/admin/users`、`/admin/orders`、`/admin/roles`、`/admin/feature-flags` 至少各看一眼)。 + admin 可能隐性依赖了 `globals.css` 里的某些 token 或 reset—— + 真依赖了就把那部分显式搬进 `admin.css`,不要把整个 `globals.css` 搬回去。 +- **验收取证:** 给出 `/admin` 首屏 CSS 体积的改动前后对比(来自任务 0 记下的 build 输出)。 + +**止损条款:** 出现说不清的样式塌陷就还原,写进 `BLOCKED.md`。 + +--- + +## 不在本轮范围 + +- **深色主题(审计第 09 条)。** `globals.css` 是 `color-scheme: light`、0 处 `prefers-color-scheme`。 + 单一浅色主题完全可能是刻意选择——`DESIGN.md` 把这套视觉定义为"私人阅读室",纸感暖白是它的立身之本。 + 问题是 23,000 字的设计文档通篇没提这件事。 + **这是产品决策,不是代码任务,需要人来拍板,agent 不要自行开工。** + 本轮唯一动作:在进度文档里把这个待决策项列出来,交给决策者。 +- **`globals.css` 的完整分包**(任务 5 的下一步)。 +- **`Home` 的完整拆分**(任务 4 的第 2、3 块)。 + +--- + +## 收尾 + +1. **`docs/BUG_HISTORY.md`**:任务 2.5(重叠 effect)与任务 2.2(字体静默回退)属于第 7 节定义的 + "异常 / 回归"范畴,需要建档。 + **写之前先检索当前最大编号**——本地看到的是 `BUG-428`,但 `origin/staging` 可能已经推进并占用了后续号, + BUG-253 / BUG-255 记录过两次抢号失效。以远端实际最大编号顺延,并在记录里注明。 +2. **进度文档**:新建 `PROGRESS-frontend-optimization-20260828.md`(**不要写 `PROGRESS.md`**, + 根目录已有受版本控制的 `progress.md`,且文件系统大小写不敏感,会覆盖清单外的文件—— + 这个坑 `PROGRESS-react-compiler-20260817.md` 开头记过)。 + 内容:任务 0 的复核表、每个任务的取证读数与反向验证结果、止损触发情况、第 09 条待决策项。 +3. **`BLOCKED.md`**:所有触发止损的任务,按既有格式追加。 +4. **提交前**:`npx tsc --noEmit` 无输出、`npx eslint` 0 error、 + 测试数 ≥ 基线且 `skipped=0` / `fail=0`、`npx next build` 退出码 0。 +5. **推送**:`git push origin HEAD:staging` 是快进推送,会触发 `backend-quality-gate`, + **没有路径过滤,任何改动(包括纯文档)都会跑完整构建与部署**—— + 所以**同批改动合并成一次推送**,不要分五次推。 + 推送后核对远端 SHA,并确认 `GET /api/health` 的 `.deployment.gitCommit` 等于本次 SHA, + 否则视为未部署。 +6. **不要自行提升到 `main`。** 交付到 `staging` 并在 `https://staging.jyotisha.chat` 完成验收后停下, + 由人决定是否提升。 + +--- + +## 交付物清单 + +- [ ] 任务 0 复核表(含与审计数字的差异说明) +- [ ] 任务 1 渲染基准脚本 + 改动前读数(或止损说明) +- [ ] 任务 2 五条清理(每条独立验证;2.1 附 build 渲染模式对比) +- [ ] 任务 3 消息列表拆分 + 前后读数 + 反向验证(或止损说明) +- [ ] 任务 4 第一刀 + `Home` 的 `useState` 前后数字 + 前后读数(或止损说明) +- [ ] 任务 5 admin 样式隔离 + CSS 体积前后对比(或止损说明) +- [ ] `docs/BUG_HISTORY.md` 建档(编号已核远端) +- [ ] `PROGRESS-frontend-optimization-20260828.md` +- [ ] `BLOCKED.md`(若有止损触发) +- [ ] 第 09 条深色主题待决策项已上交