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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8
This commit is contained in:
@@ -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 条深色主题待决策项已上交
|
||||
Reference in New Issue
Block a user