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
20 KiB
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(已部署,healthdeployment.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 轮串行接口是第二段。
另外三条静态事实:
- 每次部署都会让浏览器缓存全部失效。
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"。 - 宋体的体积。
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)。 - 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 已删):
- 首屏 JS 构成。 首页 HTML 引用 30 个 chunk,未压缩 2050 KB、brotli 551 KB。其中
0cz1d0mv5g_q7.js(约 110 KB)是noModule补丁,新浏览器不下载,所以现代浏览器实际约 1940 KB / 520 KB。按来源(未压缩):next465 KB、@base-ui/react约 200 KB(扣掉下面第 5 条的误归因)、应用自身代码约 713 KB(其中名字含rectification/birth-time的源文件共约 226 KB)、zod60 KB、date-fns52 KB、react-day-picker42 KB、markdown 系(micromark*/mdast*/hast*/property-information/react-markdown)约 80 KB。 - 全国城市表在首屏。
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等以值的方式导入,进了首屏 chunk31sim7mf4kv30.js(source map 把它误归到@base-ui/react/utils/useOpenInteractionType.mjs)。用途是兼容只存了provinceCode/ 城市编码的老资料,以及出生地选择器。 - 研究数据进了首屏。 首屏 chunk
3spnw3pe9v7ng.js里有一段约 20 KB 的JSON.parse('{"sealed_benchmark_id":"minute_rectification_fact_ranker_v4_holdout_v3",…}')(校正研究的封存评测结果)。 - 接口必须等 JS 跑完才发。 §1 表里首个接口出现在最后一个首屏 JS 下完之后(约 3100 ms),DOMContentLoaded 之后约 1 秒完全没有网络请求可以并行。账户、人物目录、会话列表的请求都不依赖 JS 内容,本可以在 HTML 一到就发出。
- 已排除:传输压缩正常(JS 响应
content-encoding: zstd);两台服务器都在国内(staging118.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)
- 加载屏改用黑体:「正在载入账户 / 正在准备对话」这一屏的标题不再用宋体。其他标题保持
TASK-serif-headings-20260928定下的宋体,不改字形。 - 宋体只下一次:宋体切片在部署之间必须保持同一地址、长期缓存,部署后不再重新下载。为此允许改变切片的存放和引用方式(见 T3)。如果最终走 T3-b,就推翻
frontend/tests/serif-headings-contract.test.ts里「slices are bundled by Next, not served from public/」这条断言(原因:Next 打包的资源带?dpl=,每次部署都失效)。改断言要写「原值 / 新值 / 原因」三栏。 - 没有采用的方案:苹果设备优先用系统自带宋体、标题全部改回黑体。
- 接口并行化、按需 JS 预取属于纯工程优化,不改变可见行为,按本单红线执行即可。
- 追加(产品 2026-09-30「追加」):首屏 JS 瘦身(T7)、接口在 HTML 到达时就发出(T8),两项都不改可见行为。
- 未决,不在本单范围:「新开页面先显示上次的账户和会话列表,后台再刷新」需要把账户与会话标题存进设备本地存储,和 BUG-1040 暖快照「只放内存」的设计冲突,涉及隐私取舍,等产品拍板后另开单。本单不得把这些数据写进 localStorage / sessionStorage / IndexedDB。产品 2026-09-30 同意暂不做,本单验收后按真机掐表再议。
4. 硬红线
- 不得去掉
deploymentId:它修的是 BUG-936 与报告页 ChunkLoadError(旧标签页在发布后加载不到新 chunk)。T4 只能在保留它的前提下做。 - 不得出现半揭幕(BUG-1021),不得在切换账号时先画出上一个账号的数据(BUG-1040
bindCurrentSubjectAccount逻辑),加载屏不得新增 spinner 或骨架(AGENTS §6)。 - 并行预取出来的「本人会话列表」只能在当前人物确认为
self时使用;确认是他人时必须丢弃,按他人重取,界面上不得闪出本人的会话。 (app)/page.tsx的Home()状态数不得增长(home-shell-growth-contract.test.ts);新逻辑进src/lib/或 hook。- 不改
.gitea/workflows/**、不改 DNS、不升级依赖。改next.config.ts可以,但必须真跑生产构建和 standalone 验证(见 T4)。 - 宋体切片文件的字节内容不变(
scripts/fonts/build_serif_slices.py的产物逐字节一致),只改存放位置、文件名和引用方式。 - T7 只改加载时机,不改功能:老资料(只有省市编码、没有经纬度)的出生地解析结果必须逐字段不变,出生地选择器、校正、生日录入、消息渲染的行为全部不变;挪出首屏后,第一次用到时允许短暂加载,但不得出现 spinner 或骨架(AGENTS §6)。
- T8 的早发请求只在
/的首屏用一次,被消费后作废;401 必须照旧走登录跳转;账号不一致(BUG-1040)时丢弃;不得让任何页面因为早发失败而卡住,早发失败就回到正常请求。 - 改动或删除断言前,按 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。先确认它的结果不依赖会话列表;如果有依赖,在进度记录里写清并保留该依赖。 - 验收:
- 单元测试:mock fetch,断言冷启动时 account、chart-profiles、sessions 三个请求在同一轮发出(没有一个要等另一个 resolve)。另加一条「人物目录回来发现是他人」的用例:本人会话不上屏,最终列表是他人的。
- 用 §7 的测量脚本,接口统一延迟 600 ms:从首个接口发出到揭幕,串行轮数由 4 降到不超过 2,进度记录贴前后对照表。
- 既有 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 支持时启用可能让部署坏掉,所以必须实测:next build后产物里有/_next/static/immutable/*,standalonenext start能以不带?dpl的地址返回这些文件,响应头immutable;- 用同一份代码、两个不同的
NEXT_DEPLOYMENT_ID各 build 一次,未改动的 chunk 在两次构建中路径相同; - 旧标签页场景不回退:用 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)
按收益从大到小,逐项挪出首屏:
- 城市表:
china-locations.json改为用到时再加载(动态import();或把「省市编码 → 经纬度 / 时区」这类老资料兼容查询挪到服务端)。home-types.ts不再以值的方式导入城市表。 - 研究评测 JSON:找出是哪个模块把封存评测结果带进客户端(搜
sealed_benchmark_id/fact_ranker_v4_holdout),客户端只保留用得到的字段,或者改成服务端提供。 - 校正 / 生时录入代码:空白首页不渲染的校正界面(
rectification-agentic-chat、rectification-board、birth-time-intake等及其src/lib/rectification-agentic/**依赖)改为按需加载,参考chat-chunk-prefetch.ts在首屏就绪后空闲预取。 - 日期选择器与日期库(
react-day-picker、date-fns):只在生日录入时加载。 - markdown 渲染:空白首页没有消息时不加载;有消息的会话按需加载,并在首屏就绪后空闲预取。
- 验收:
- 用 Claude 的方法(
productionBrowserSourceMaps临时构建 + source map 按来源统计,不提交该配置)给出前后对照表:首页首屏 JS(排除noModule)的 brotli 总量,城市表与研究 JSON 不再出现在首屏 chunk 中。 - 老资料兼容:新增或保留一条测试,对只有省市编码的虚构资料,解析结果(经纬度、时区、地名)与改动前逐字段一致。
- 首屏 gzip 预计明显下降,超过 ±2% 是预期内的,在进度记录里写明原因和数字,不按超标处理。
- 用 §7 的测量脚本,最后一个首屏 JS 的完成时刻提前(贴前后对照)。
- 用 Claude 的方法(
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 仍须交付。
- 验收:
- 测量脚本(接口延迟 600 ms):
/api/account的发出时刻早于最后一个首屏 JS 完成;揭幕时刻 ≈ max(JS 就绪, 早发接口返回) + 1 轮以内。贴前后对照。 - 单元测试:早发结果被消费一次后作废;早发返回 401 时照旧跳登录;早发失败(网络错误)时回退到正常请求并成功揭幕;账号 id 与本地记录不一致时丢弃早发的会话列表。
- 除
/外的页面不发这些早发请求(内联脚本只在首页 HTML 里)。
- 测量脚本(接口延迟 600 ms):
T6 · 记录
- BUG-1127~1130 补齐修复、验证、防复发,改为 resolved;CHANGELOG 一条;
DESIGN.md(T2);新增docs/testing/home-first-load-20260930.md真机清单:①部署后第一次打开首页,计时到能输入;②刷新再计时;③隔一次部署再打开,计时并看标题宋体是否瞬间出现;④切到他人档案后刷新,确认没有闪出本人的会话。
6. 验收口径
tsc --noEmit0 错;npm run lint0 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. 开工前置命令
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