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

20 KiB
Raw Blame History

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 已删):

  1. 首屏 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。
  2. 全国城市表在首屏。 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 / 城市编码的老资料,以及出生地选择器。
  3. 研究数据进了首屏。 首屏 chunk 3spnw3pe9v7ng.js 里有一段约 20 KB 的 JSON.parse('{"sealed_benchmark_id":"minute_rectification_fact_ranker_v4_holdout_v3",…}')(校正研究的封存评测结果)。
  4. 接口必须等 JS 跑完才发。 §1 表里首个接口出现在最后一个首屏 JS 下完之后(约 3100 ms),DOMContentLoaded 之后约 1 秒完全没有网络请求可以并行。账户、人物目录、会话列表的请求都不依赖 JS 内容,本可以在 HTML 一到就发出。
  5. 已排除:传输压缩正常(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. 开工前置命令

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