Files
Jyotisha/docs/tasks/TASK-home-first-load-20260930.md
T
Jesse_ChenandClaude Opus 5.5 856f611fcf docs(home): records for the home first-load round (BUG-1127..1130); measurement script lint fix
Progress with before/after, T4 evidence (supportsImmutableAssets has no
effect under self-hosted standalone) in BLOCKED.md, device checklist,
changelog, board row to 待验收.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 00:57:52 +08:00

178 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK · 进站等待太久:首页首开与字体加载 · 2026-09-30
> 执行方:coding agent。验收:Claude。
> 分支 `codex/home-first-load-20260930`,worktree `.worktrees/home-first-load-20260930`,基线 `origin/staging`。
> BUG 编号:**BUG-1127~1130**(Claude 写单时已登记为 investigating;开工时核对 `docs/BUG_HISTORY.md` 最大号,被占用就顺延并同步改本单)。
> 涉及发布形态(静态资源缓存、`next.config.ts`),开工前读 AGENTS §9 与 `docs/research/pre_work_error_ledger.md`,跑 `python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`。
> **串行**:T1 / T8 改 `session-list-context.tsx` / `home-bootstrap-run.ts` / 根 `layout.tsx`,同期有别的单改这些文件时先合入对方再开工。
> **2026-09-30 追加**:产品看过「除字体外的其他原因」后说「追加」,本单加入 T7(首屏 JS 瘦身)和 T8(接口与 JS 同时发)。
## 0. 基线
- 写单时 `origin/staging` = `d87ec44a`(已部署,health `deployment.gitCommit` 同值)。开工以实测为准,写进 `docs/tasks/PROGRESS-home-first-load-20260930.md`。
## 1. 事故实证
产品 2026-09-30 真机(iPhone,5G):进站长时间停在「正在载入账户 / 同步个人资料与对话记录」,浏览器进度条走到约八成还在转;标题的宋体也要过很久才出来。
Claude 的测量方法:无头 Chrome 打开 staging 线上 `/`,用 CDP 拦截所有 `/api/*`,统一延迟 600 ms 后返回虚构账户数据,记录每个请求的起止时间和揭幕时刻。这台机器走代理,**绝对秒数不代表用户网络**,只用来看要等几轮、谁排在谁后面。连测多次,结果稳定:
| 时刻(ms) | 事件 |
| --- | --- |
| ~2100–2300 | DOMContentLoaded |
| 2300–3100 | 首屏 29 个 JS 陆续下完(压缩后约 690 KB)。「正在载入账户」这一行还触发 4 个宋体切片(00/01/03/04,共约 170 KB),和 JS 抢带宽 |
| ~3100 | 首个接口才发出:`/api/account` 与 `/api/models`、`/api/consult/status` 并行 |
| +600 | `/api/chart-profiles`(人物目录),**等账户回来才发** |
| +600 | `/api/sessions?subject=self`,**等人物目录回来才发** |
| +600 | `/api/rectification/cases/entry-summary` 等,**等会话列表回来才发** |
| 4800–5300 | 准备阶段再按需下载 6~7 个小 JS |
| ~5700 | 揭幕 |
接口延迟设为 0 时约 3300 ms 揭幕:JS 下载是第一段大头,4 轮串行接口是第二段。
另外三条静态事实:
1. **每次部署都会让浏览器缓存全部失效。** `next.config.ts` 设了 `deploymentId: process.env.NEXT_DEPLOYMENT_ID`(= git SHA,见 `deploy/railway-web.Dockerfile`)。Next 因此给所有 `/_next/static` 资源(JS、CSS,以及 CSS 里 `url()` 引用的字体)加上 `?dpl=<SHA>`。实测线上字体地址形如 `…/jyotisha-serif-sc-00.3aonnjsygjcpp.woff2?dpl=d87ec44a…`。文件内容没变,地址也会变,所以每次部署后都要重新下载约 690 KB JS 和页面用到的所有宋体切片。staging 一天部署多次,用户几乎每次打开都是冷启动。Next 文档 `supportsImmutableAssets.md` 原话:"browsers have to download static assets again after each new deployment, even when those assets have not changed"。
2. **宋体的体积。** `src/app/fonts/serif-sc/` 共 30 个切片、2.1 MB,`font-display: swap`、不 preload。切片 01–19 按字频分组,每片 40–62 KB。一行 6 个汉字的标题通常命中 3~4 片(「正在载入账户」3 片约 130 KB;「印度占星星盘报告出生资料与图盘」3 片约 129 KB)。
3. **4 轮串行的来源**(按符号定位):
- `session-list-context.tsx` → `loadSessionList`:`await readBootAccount()` → `await loadSubjectCatalog()` → 才 `fetch('/api/sessions?…&subject=' + readCurrentSubjectId())`。
- `(app)/page.tsx` 里取 `fetchRectificationEntrySummary` 的 effect,条件是 `bootstrapPhase !== "account" && accountId && sessionListSettled`,所以要等会话列表。
- 冷启动的暖快照(BUG-1040 `home-warm-start.ts`)只在同一个页面文档里回首页时生效,新开页面永远走冷路径。
追加实测(2026-09-30,Claude 在临时 worktree 里对 `origin/staging` 打开 `productionBrowserSourceMaps` 做生产构建,按 source map 把首页 `/` 引用的每个 JS 字节归到源文件或依赖包;构建后 worktree 已删):
4. **首屏 JS 构成。** 首页 HTML 引用 30 个 chunk,未压缩 2050 KB、brotli 551 KB。其中 `0cz1d0mv5g_q7.js`(约 110 KB)是 `noModule` 补丁,新浏览器不下载,所以现代浏览器实际约 1940 KB / 520 KB。按来源(未压缩):`next` 465 KB、`@base-ui/react` 约 200 KB(扣掉下面第 5 条的误归因)、应用自身代码约 713 KB(其中名字含 `rectification` / `birth-time` 的源文件共约 226 KB)、`zod` 60 KB、`date-fns` 52 KB、`react-day-picker` 42 KB、markdown 系(`micromark*` / `mdast*` / `hast*` / `property-information` / `react-markdown`)约 80 KB。
5. **全国城市表在首屏。** `src/data/china-locations.json`(未压缩 227 KB,brotli 47 KB,实测)通过 `china-locations.ts` 被 `src/lib/home-types.ts`(`export const china = chinaLocations.country`)、`global-birth-payloads.ts`、`birth-rectification-payload.ts` 等以值的方式导入,进了首屏 chunk `31sim7mf4kv30.js`(source map 把它误归到 `@base-ui/react/utils/useOpenInteractionType.mjs`)。用途是兼容只存了 `provinceCode` / 城市编码的老资料,以及出生地选择器。
6. **研究数据进了首屏。** 首屏 chunk `3spnw3pe9v7ng.js` 里有一段约 20 KB 的 `JSON.parse('{"sealed_benchmark_id":"minute_rectification_fact_ranker_v4_holdout_v3",…}')`(校正研究的封存评测结果)。
7. **接口必须等 JS 跑完才发。** §1 表里首个接口出现在最后一个首屏 JS 下完之后(约 3100 ms),DOMContentLoaded 之后约 1 秒完全没有网络请求可以并行。账户、人物目录、会话列表的请求都不依赖 JS 内容,本可以在 HTML 一到就发出。
8. 已排除:传输压缩正常(JS 响应 `content-encoding: zstd`);两台服务器都在国内(staging `118.26.111.127`、正式站 `118.194.235.34`);`/api/account` 服务端是本机数据库的 5 次顺序查询(`GET` 里 `auth.getUser` → profile → avatar → subscriptions → `isAdminUser`),估计几十毫秒级,不是主因,本单不动。
## 2. 根因
- **BUG-1127**:首页冷启动的接口是串行的(账户 → 人物目录 → 会话列表 → 校正入口),其实只有「会话列表要知道当前人物」这一条是真依赖,而当前人物在绝大多数情况下是本地已记住的 `self`。
- **BUG-1128**:`deploymentId` 的 `?dpl=` 让内容寻址的静态资源在每次部署后都失效,字体也一样。
- **BUG-1129**:加载屏标题 `.app-loading-content strong` 用 `--font-display`(宋体),JS 还没跑,就先触发 3~4 个宋体切片下载,和关键 JS 抢带宽;换体时标题还会跳一下。
- **BUG-1130**:首屏 JS 里带着首屏用不到的模块:全国城市表(brotli 47 KB)、校正 / 生时录入代码、日期选择器与日期库、markdown 渲染、研究评测 JSON。估计合计可以挪走约 150 KB(brotli),约占首屏 JS 三成(只有城市表一项是实测,其余按未压缩大小折算)。
- BUG-1127 的另一半:冷启动接口要等全部首屏 JS 执行完才开始发(§1 第 7 条)。
## 3. 决策记录(产品 2026-09-30)
1. **加载屏改用黑体**:「正在载入账户 / 正在准备对话」这一屏的标题不再用宋体。其他标题保持 `TASK-serif-headings-20260928` 定下的宋体,不改字形。
2. **宋体只下一次**:宋体切片在部署之间必须保持同一地址、长期缓存,部署后不再重新下载。为此允许改变切片的存放和引用方式(见 T3)。如果最终走 T3-b,就**推翻** `frontend/tests/serif-headings-contract.test.ts` 里「slices are bundled by Next, not served from public/」这条断言(原因:Next 打包的资源带 `?dpl=`,每次部署都失效)。改断言要写「原值 / 新值 / 原因」三栏。
3. 没有采用的方案:苹果设备优先用系统自带宋体、标题全部改回黑体。
4. 接口并行化、按需 JS 预取属于纯工程优化,不改变可见行为,按本单红线执行即可。
5. **追加(产品 2026-09-30「追加」)**:首屏 JS 瘦身(T7)、接口在 HTML 到达时就发出(T8),两项都不改可见行为。
6. **未决,不在本单范围**:「新开页面先显示上次的账户和会话列表,后台再刷新」需要把账户与会话标题存进设备本地存储,和 BUG-1040 暖快照「只放内存」的设计冲突,涉及隐私取舍,等产品拍板后另开单。本单**不得**把这些数据写进 localStorage / sessionStorage / IndexedDB。产品 2026-09-30 同意暂不做,本单验收后按真机掐表再议。
## 4. 硬红线
1. **不得去掉 `deploymentId`**:它修的是 BUG-936 与报告页 ChunkLoadError(旧标签页在发布后加载不到新 chunk)。T4 只能在保留它的前提下做。
2. 不得出现半揭幕(BUG-1021),不得在切换账号时先画出上一个账号的数据(BUG-1040 `bindCurrentSubjectAccount` 逻辑),加载屏不得新增 spinner 或骨架(AGENTS §6)。
3. 并行预取出来的「本人会话列表」只能在当前人物确认为 `self` 时使用;确认是他人时必须丢弃,按他人重取,界面上不得闪出本人的会话。
4. `(app)/page.tsx` 的 `Home()` 状态数不得增长(`home-shell-growth-contract.test.ts`);新逻辑进 `src/lib/` 或 hook。
5. 不改 `.gitea/workflows/**`、不改 DNS、不升级依赖。改 `next.config.ts` 可以,但必须真跑生产构建和 standalone 验证(见 T4)。
6. 宋体切片文件的字节内容不变(`scripts/fonts/build_serif_slices.py` 的产物逐字节一致),只改存放位置、文件名和引用方式。
7. T7 只改加载时机,不改功能:老资料(只有省市编码、没有经纬度)的出生地解析结果必须逐字段不变,出生地选择器、校正、生日录入、消息渲染的行为全部不变;挪出首屏后,第一次用到时允许短暂加载,但不得出现 spinner 或骨架(AGENTS §6)。
8. T8 的早发请求只在 `/` 的首屏用一次,被消费后作废;401 必须照旧走登录跳转;账号不一致(BUG-1040)时丢弃;不得让任何页面因为早发失败而卡住,早发失败就回到正常请求。
9. 改动或删除断言前,按 AGENTS §7-8 在 `frontend/tests/` 和 `tests/` 里 grep;已知相关:`serif-headings-contract.test.ts`、`font-stack-loadable-contract.test.ts`、`tests/test_serif_font_slices.py`、`home-shell-growth-contract.test.ts`,以及 BUG-1040 / BUG-1104 的暖快照测试。
## 5. 任务分解
### T1 · 冷启动接口并行(BUG-1127)
- `loadSessionList`:`readBootAccount()`、`loadSubjectCatalog()`,以及「按本地记住的当前人物(缺省 `self`)取会话列表」三者同时发出。账户回来后校验账户 id(BUG-1040);人物目录回来后如果当前人物与预取时用的不同,丢弃预取结果,按正确人物再取一次。
- 校正入口摘要(`fetchRectificationEntrySummary`)在账户 id 已知时就发,不再等 `sessionListSettled`。先确认它的结果不依赖会话列表;如果有依赖,在进度记录里写清并保留该依赖。
- 验收:
1. 单元测试:mock fetch,断言冷启动时 account、chart-profiles、sessions 三个请求在同一轮发出(没有一个要等另一个 resolve)。另加一条「人物目录回来发现是他人」的用例:本人会话不上屏,最终列表是他人的。
2. 用 §7 的测量脚本,接口统一延迟 600 ms:从首个接口发出到揭幕,**串行轮数由 4 降到不超过 2**,进度记录贴前后对照表。
3. 既有 BUG-1021 / 1040 / 1052 / 1104 相关测试全绿、断言不动。
### T2 · 加载屏用黑体(BUG-1129)
- `.app-loading-content strong`(以及同一加载屏里其他用 `--font-display` 的元素)改用正文黑体栈。
- `frontend/DESIGN.md` 的加载屏一节同一提交写明:加载屏只用黑体,理由是 JS 就绪前不触发网络字体下载。
- 验收:测量脚本里,`load` 事件之前没有任何 `jyotisha-serif-sc-*` 请求。
### T3 · 宋体切片只下一次(BUG-1128 字体部分)
按顺序尝试,以 T4 的结果为准:
- **T3-a(优先)**:T4 成立时,确认字体切片也走不带 `?dpl` 的不变地址。成立就不用搬文件。
- **T3-b(T4 不成立时)**:切片搬到 `public/fonts/serif-sc/`,文件名带内容哈希(例如 `jyotisha-serif-sc-00.<sha256 前 10 位>.woff2`),`serif-sc.css` 改用绝对路径引用。`next.config.ts` 的 `headers()` 给 `/fonts/serif-sc/:path*` 加 `Cache-Control: public, max-age=31536000, immutable`。`build_serif_slices.py`、`SOURCE.txt` 跟着更新命名规则。
- 验收:staging 部署后,字体地址不带 `?dpl=`,响应头是 `immutable`;连续两次部署,同一切片地址不变(进度记录贴两次的地址);`test_serif_font_slices.py` 等合同测试按三栏更新后通过。
### T4 · JS 不随部署失效(BUG-1128 JS 部分,先验证、不成立就不上)
- 在 `next.config.ts` 试 `supportsImmutableAssets: true`(Next 16.3 起提供,见 `node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/supportsImmutableAssets.md` 与 `07-adapters/12-immutable-static-assets.md`)。文档说明没有 adapter 支持时启用可能让部署坏掉,所以必须实测:
1. `next build` 后产物里有 `/_next/static/immutable/*`,standalone `next start` 能以不带 `?dpl` 的地址返回这些文件,响应头 `immutable`;
2. 用同一份代码、两个不同的 `NEXT_DEPLOYMENT_ID` 各 build 一次,未改动的 chunk 在两次构建中路径相同;
3. 旧标签页场景不回退:用 A 版本的页面,服务端换成 B 版本,再做一次客户端导航,仍然整页刷新、不报 ChunkLoadError(对照 BUG-936 与报告页那条的复现方式)。
- 三条都满足才上线;任一条不满足就不改配置,把实测结果写进 `BLOCKED.md`,并走 T3-b。
- 验收:进度记录贴三条的原始证据(目录列表、curl 响应头、两次构建的路径对照)。
### T5 · 准备阶段的按需 JS 提前下载(可选)
- 找出揭幕前按需加载的 6~7 个 chunk(测量脚本里 4800–5300 ms 那批),能在首屏 JS 就绪后立刻预取的就预取(参考 `chat-chunk-prefetch.ts` 的做法)。首屏 gzip 仍须在 ±2% 内。
- 验收:测量脚本里这批 chunk 的完成时间早于账户接口返回。
### T7 · 首屏 JS 瘦身(BUG-1130)
按收益从大到小,逐项挪出首屏:
1. **城市表**:`china-locations.json` 改为用到时再加载(动态 `import()`;或把「省市编码 → 经纬度 / 时区」这类老资料兼容查询挪到服务端)。`home-types.ts` 不再以值的方式导入城市表。
2. **研究评测 JSON**:找出是哪个模块把封存评测结果带进客户端(搜 `sealed_benchmark_id` / `fact_ranker_v4_holdout`),客户端只保留用得到的字段,或者改成服务端提供。
3. **校正 / 生时录入代码**:空白首页不渲染的校正界面(`rectification-agentic-chat`、`rectification-board`、`birth-time-intake` 等及其 `src/lib/rectification-agentic/**` 依赖)改为按需加载,参考 `chat-chunk-prefetch.ts` 在首屏就绪后空闲预取。
4. **日期选择器与日期库**(`react-day-picker`、`date-fns`):只在生日录入时加载。
5. **markdown 渲染**:空白首页没有消息时不加载;有消息的会话按需加载,并在首屏就绪后空闲预取。
- 验收:
1. 用 Claude 的方法(`productionBrowserSourceMaps` 临时构建 + source map 按来源统计,**不提交该配置**)给出前后对照表:首页首屏 JS(排除 `noModule`)的 brotli 总量,城市表与研究 JSON 不再出现在首屏 chunk 中。
2. 老资料兼容:新增或保留一条测试,对只有省市编码的虚构资料,解析结果(经纬度、时区、地名)与改动前逐字段一致。
3. 首屏 gzip 预计明显下降,超过 ±2% 是预期内的,在进度记录里写明原因和数字,不按超标处理。
4. 用 §7 的测量脚本,最后一个首屏 JS 的完成时刻提前(贴前后对照)。
### T8 · 接口在 HTML 到达时就发出(BUG-1127 追加)
- 首页 `/` 的静态 HTML 在 `<head>` 里加一段**内联经典脚本**(不依赖 bundle;语法照 BUG-936 兜底脚本的口径,保持 ES5),HTML 一到就发出 `/api/account`、`/api/chart-profiles`、`/api/sessions?limit=…&subject=<本地记住的人物,缺省 self>`、`/api/models`,把 Promise 挂在一个命名清楚的全局上。`readBootAccount` / `loadSubjectCatalog` / `loadSessionList` / `fetchModelCatalog` 首次调用时优先消费它,消费后清掉。
- 请求参数(limit、subject、headers、`cache: "no-store"`、`credentials`)必须和正常路径完全一致,写一个共享常量,防止两边漂移。
- 不采用 `<link rel="preload" as="fetch">`:它和 `cache: "no-store"` 的匹配行为依赖浏览器实现,不可控。
- 与 T1 的关系:T1 是 JS 内部并行,T8 让这些请求再提前到和 JS 下载同时进行。两项都做;T8 做不成时 T1 仍须交付。
- 验收:
1. 测量脚本(接口延迟 600 ms):`/api/account` 的发出时刻早于最后一个首屏 JS 完成;揭幕时刻 ≈ max(JS 就绪, 早发接口返回) + 1 轮以内。贴前后对照。
2. 单元测试:早发结果被消费一次后作废;早发返回 401 时照旧跳登录;早发失败(网络错误)时回退到正常请求并成功揭幕;账号 id 与本地记录不一致时丢弃早发的会话列表。
3. 除 `/` 外的页面不发这些早发请求(内联脚本只在首页 HTML 里)。
### T6 · 记录
- BUG-1127~1130 补齐修复、验证、防复发,改为 resolved;CHANGELOG 一条;`DESIGN.md`(T2);新增 `docs/testing/home-first-load-20260930.md` 真机清单:①部署后第一次打开首页,计时到能输入;②刷新再计时;③隔一次部署再打开,计时并看标题宋体是否瞬间出现;④切到他人档案后刷新,确认没有闪出本人的会话。
## 6. 验收口径
- `tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 失败清单与基线逐条一致,测试总数不减少(按测试名比对)。
- `next build` 后 `/` 仍是 `○ Static`;首屏 gzip ±2%。
- Python 有改动(`scripts/fonts/`、`tests/test_serif_font_slices.py`)时跑快速门全量。
- 部署后 Claude 用同一测量脚本在 staging 复测,贴前后对照。
## 7. 测量脚本
Claude 的脚本在会话 scratchpad,不入库;执行方按以下口径自己写一个放进 `frontend/scripts/`(只做测量,不进 CI):
- 用 `/exec-daemon/node` + Node 22 自带 `WebSocket` 连无头 Chrome 的 CDP。
- `Fetch.enable` 拦截全部请求:`/api/*` 按固定延迟返回虚构数据(账户、`/api/models` 目录、`/api/sessions` 空列表,其余返回 404),静态资源放行。
- 记录 `Network.requestWillBeSent` / `loadingFinished`、`Page.domContentEventFired` / `loadEventFired`,每 100 ms 检查 `.app-loading`(用 `getClientRects().length`,不能用 `offsetParent`,因为它是 fixed 定位),以它消失为揭幕时刻。
## 8. 让步顺序
T2 → T1 → T8 → T7 → T3 → T4 → T5。T2、T1 必做;T8、T7 应做(T7 至少完成第 1、2 项:城市表、研究 JSON);T3 必须以 a 或 b 之一落地;T4 不成立就记 BLOCKED;T5 可以留到下一单。
## 9. 开工前置命令
```bash
cd /workspace/Jyotisha && git status -sb | head -1
git fetch origin --prune
git worktree add -b codex/home-first-load-20260930 .worktrees/home-first-load-20260930 origin/staging
cd .worktrees/home-first-load-20260930/frontend && npm ci # Node 22 在 /exec-daemon/node
grep -o "BUG-[0-9]\{3,4\}" ../docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
python3 ../scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45
```