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
178 lines
20 KiB
Markdown
178 lines
20 KiB
Markdown
# 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
|
||
```
|