Files
Jyotisha/docs/tasks/TASK-first-paint-dead-screen-fallback-20260917.md
T

106 lines
9.6 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.
# 任务书 · 首屏死屏兜底:客户端 bundle 没跑起来时,「正在载入账户」永远转下去(2026-09-17)
## 0. 基线
- 基线 commit:`db5b0a21`(`origin/staging` head)。线上 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. 硬红线
1. 兜底脚本必须是 `<script>`(非 `type="module"`)、ES5 语法、内联在根 layout 的 `<head>` 或 `<body>` 顶部;不得 import、不得用箭头函数 / `const` / 模板串以外的现代语法(它要在挂掉的那些引擎上也能跑)。
2. 正常情况下兜底**不得**闪现:只有在超时且页面仍是未 hydrate 状态时才显示。
3. 不得自动重载页面。
4. 不得改 `page.tsx` 的揭幕门逻辑、不得动 8 秒 `bootstrapTimeout`。
5. `/` 必须仍是 `○ Static`;首屏 gzip ±2%。
6. 既有断言不得静默弱化;改任何断言写「原值 / 新值 / 原因」三栏。
## 5. 任务分解
### T1 根 layout 内联死屏兜底(BUG-937)
- `frontend/src/app/layout.tsx`:在现有 `themePreferenceBootScript` 旁边加第二段内联经典脚本,逻辑:
1. 记录启动时刻,`setTimeout` 12000ms(大于 JS 侧 8 秒超时 + 4 秒揭幕,正常路径永远不会命中)。
2. 触发时检查页面是否仍停在首屏加载态(推荐用 `document.querySelector(".app-loading")` 仍在,且 `document.documentElement.dataset.hydrated !== "1"`)。
3. 仍在 → 把 `.app-loading-content` 的内容替换成兜底文案 + 「重新加载」按钮(`location.reload()`)。文案对照 `frontend/docs/VOICE.md` 后定稿,建议:标题「这个页面没能加载完」,正文「网络中断或浏览器版本过旧都会这样。可以重新加载试一次;如果反复出现,请在 Safari 设置里清除本站数据,或把系统升级到 iOS 16.4 以上。」
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*|\n+/u)` |
| `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. 开工前置命令
```bash
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 的教训)。