Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
9.6 KiB
任务书 · 首屏死屏兜底:客户端 bundle 没跑起来时,「正在载入账户」永远转下去(2026-09-17)
0. 基线
- 基线 commit:
db5b0a21(origin/staginghead)。线上 staging 仍是dc2f2a16,今天六个代码提交都没部署。 - 分支:
codex/first-paint-dead-screen-fallback-20260917,git worktree add -b codex/first-paint-dead-screen-fallback-20260917 .worktrees/first-paint-dead-screen-fallback-20260917 origin/staging。 - 范围:
frontend/src/app/layout.tsx(内联兜底脚本)、frontend/src/app/globals.css(兜底文案样式)、frontend/src/lib/八处正则后行断言、对应测试。不动 Python、不动迁移、不动 Skill、不动业务逻辑。 - BUG 段:BUG-937 起(BUG-936 是本单的诊断记录,已
investigating;开工时核对最大号)。
1. 事故实证
产品负责人 2026-09-17 转述:一位已有账号的用户在 iPhone Safari 打开 https://staging.jyotisha.chat/,永远停在「正在载入账户 / 同步个人资料与对话记录」,进不去页面,转圈还在动。
已确认(线上 dc2f2a16,用该用户登录态只读核对,未写入任何数据):
| 事实 | 证据 |
|---|---|
| 不是接口问题 | /api/account 0.72 s、/api/sessions?limit=40 1.07 s、/api/models 0.61 s、/api/rectification/cases/entry-summary 0.84 s,全部 200 |
| 这一屏是预渲染 HTML | curl https://staging.jyotisha.chat/ 的 HTML 里直接含「正在载入账户」「同步个人资料与对话记录」与 5 处 app-loading |
| 转圈与 JS 无关 | .app-loading-orbit::after 是纯 CSS 动画,AppLoadingIndicator 不含任何脚本行为 |
| 兜底全在 JS 里 | page.tsx 的 8 秒 bootstrapTimeout(写 accountError + setHydrated(true))、prepare 阶段 4 秒揭幕、401 跳 /login,全部写在客户端 bundle 中 |
| 首屏要同步跑 25 个 chunk | 首页 HTML 引用 25 个 /_next/static/chunks/*.js,都是同步 <script>;任何一个没加载或解析失败,React 就不 hydrate |
| bundle 语法下限是 Safari 16.4 | 1stw4tc266s7a.js(Next 自己的 app-router 运行时)含 class y extends Component{static{this.contextType=…}};089-80cjt8-1t.js(本仓中文断句)与 0dmsli_y64717.js(链接识别依赖)含 (?<=…)。类静态块与后行断言在 Safari < 16.4 都是解析期错误,core-js 救不了 |
| 该下限不是新引入 | next: 16.3.1 从首个提交 4aa0105f(2026-07-15)就在 |
把这些串起来:只要那 25 个 chunk 里有一个没跑起来,用户看到的就正好是这一屏,而且永远不会变——因为所有能救场的逻辑本身就在没跑起来的那堆 JS 里。8 秒超时救不了它自己。
具体是哪一种(设备 Safari < 16.4 / 弱网下某个 chunk 没下全 / 内容拦截器 / Safari 缓存里的坏 chunk,chunk 带 cache-control: public, max-age=31536000, immutable)需要用户侧一条信息才能分流,见 docs/BUG_HISTORY.md BUG-936。本单不赌根因,做的是与根因无关的两件事。
2. 根因(本单负责的那一层)
首屏把「出错了」的唯一出口放在了可能失败的模块 bundle 里。只要 bundle 挂了,用户手上就只剩一个永远转的动画:没有文案、没有重试、没有任何线索,也没法自救。这一层与具体哪个 chunk 失败无关,必须先补上。
3. 决策记录
- 产品负责人 2026-09-17 要求查因;本单是查因结论里不依赖用户回话就能做的那部分,由 Claude 定范围。
- 口径:兜底必须是与主 bundle 无关的内联经典脚本(不是 module、不引外部文件、不依赖 React),否则同一场故障会把兜底一起带走。
- 兜底只负责说清楚和给出路,不试图修复:显示一句人话 + 「重新加载」按钮 + 一行「如果反复出现,请在 Safari 里清除本站数据,或升级到 iOS 16.4 以上」。不得自动反复刷新(会变成刷新循环)。
- 本仓八处正则后行断言一并改掉:它们是我们自己能控的那部分语法下限。Next 运行时的类静态块改不了,所以这一项不会把下限降到 16.4 以下,只是去掉我们自己的那份,并让「本仓代码不写解析期新语法」成为一条可测的约束。
- 不做:为老 Safari 降级整个构建(Next 16 的基线摆在那儿,做不到且代价失控)。不做:把 25 个 chunk 合并优化(另一个题目)。
4. 硬红线
- 兜底脚本必须是
<script>(非type="module")、ES5 语法、内联在根 layout 的<head>或<body>顶部;不得 import、不得用箭头函数 /const/ 模板串以外的现代语法(它要在挂掉的那些引擎上也能跑)。 - 正常情况下兜底不得闪现:只有在超时且页面仍是未 hydrate 状态时才显示。
- 不得自动重载页面。
- 不得改
page.tsx的揭幕门逻辑、不得动 8 秒bootstrapTimeout。 /必须仍是○ Static;首屏 gzip ±2%。- 既有断言不得静默弱化;改任何断言写「原值 / 新值 / 原因」三栏。
5. 任务分解
T1 根 layout 内联死屏兜底(BUG-937)
frontend/src/app/layout.tsx:在现有themePreferenceBootScript旁边加第二段内联经典脚本,逻辑:- 记录启动时刻,
setTimeout12000ms(大于 JS 侧 8 秒超时 + 4 秒揭幕,正常路径永远不会命中)。 - 触发时检查页面是否仍停在首屏加载态(推荐用
document.querySelector(".app-loading")仍在,且document.documentElement.dataset.hydrated !== "1")。 - 仍在 → 把
.app-loading-content的内容替换成兜底文案 + 「重新加载」按钮(location.reload())。文案对照frontend/docs/VOICE.md后定稿,建议:标题「这个页面没能加载完」,正文「网络中断或浏览器版本过旧都会这样。可以重新加载试一次;如果反复出现,请在 Safari 设置里清除本站数据,或把系统升级到 iOS 16.4 以上。」 - 同时在
window.onerror里记一个标记,兜底文案据此追加一行「(页面脚本未能执行)」,方便真机回报时区分「加载慢」和「脚本挂了」。
- 记录启动时刻,
- hydrate 成功的标记:
page.tsx揭幕后(hydrated为真)写document.documentElement.dataset.hydrated = "1"。这是一行 effect,放进现有 hook 或 layout 的客户端组件,page.tsx不增行。 - 验收:
- 新测试
frontend/tests/first-paint-fallback-contract.test.ts:layout 源码里这段脚本不含=>、const、let、class、${、?.、??(ES5 约束,源码级正则断言);不含type="module";超时值 >page.tsx的 8000 + 4000。 - jsdom 行为测试:
.app-loading存在且无data-hydrated时,触发定时器后文案被替换、按钮可点;data-hydrated="1"时不替换。 - 手工:
next build后把某个 chunk 的<script src>改错(或断网重放)验证 12 秒后出兜底;写进docs/testing/。
- 新测试
T2 去掉本仓八处正则后行断言(BUG-938)
现存位置(按符号定位,行号会漂):
| 文件 | 符号 |
|---|---|
lib/personal-report-generation.ts |
段落切分 `split(/(?<=[。!?!?;;])\s* |
lib/rectification-agentic/v9/adopt-narration.ts |
句子切分 |
lib/rectification-agentic/user-copy.ts |
OPENING_SENTENCE 与另外两处 |
lib/rectification-agentic/v9/turn-narration.ts |
两处 |
lib/rectification-agentic/v9/collect-prompt.ts |
SENTENCE_SPLIT |
- 统一换成一个共用工具
lib/sentence-split.ts:用match/手写扫描实现「在句末标点后切开、保留标点」,不使用后行断言。八处全部改为调用它。 - 行为必须逐字一致:给工具补一组表驱动测试,用现有八处的输入输出习惯各取至少两例(中文句号、问号叹号、分号、英文标点、换行、连续标点、结尾无标点)。
- 验收:
npm test相关套件全绿,rectification与报告相关的既有断言一条不改。- 源码合同:
git grep -n "(?<=" frontend/src无结果;测试里加一条正则断言把这条钉死(允许frontend/tests自己出现该字符串)。
T3 记录
docs/BUG_HISTORY.md:BUG-937、BUG-938;把 BUG-936 从investigating更新为「兜底已补、根因待用户侧信息」,不得在没有用户回话的情况下把 936 标resolved。CHANGELOG.md一句;frontend/DESIGN.md加「首屏死屏兜底」一节(出现条件、文案、唯一动作);frontend/docs/VOICE.md加兜底文案;docs/tasks/PROGRESS-first-paint-dead-screen-fallback-20260917.md;状态板行。
6. 让步顺序
T1 > T2。T1 一项独立可发,是用户当下最需要的。T2 如果表驱动测试铺不完,可先只改 user-copy.ts 三处(用户可见文案链路),其余写进 BLOCKED.md。
7. 开工前置命令
git fetch origin --prune
git worktree add -b codex/first-paint-dead-screen-fallback-20260917 .worktrees/first-paint-dead-screen-fallback-20260917 origin/staging
cd .worktrees/first-paint-dead-screen-fallback-20260917/frontend
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20 # 记下基线失败清单与总数
grep -n "^## BUG-" ../docs/BUG_HISTORY.md | tail -1
8. 验收口径
tsc --noEmit 0 错;npm run lint 0 error;全量 npm test 失败清单与基线逐条一致、新增测试全绿、总数不降;next build --webpack 通过且 / 仍 ○ Static、首屏 gzip ±2%;git grep "(?<=" frontend/src 无结果。交付前必须跑全量,不得只跑定向(BUG-933 的教训)。