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

9.6 KiB
Raw Blame History

任务书 · 首屏死屏兜底:客户端 bundle 没跑起来时,「正在载入账户」永远转下去(2026-09-17)

0. 基线

  • 基线 commitdb5b0a21origin/staging head)。线上 staging 仍是 dc2f2a16,今天六个代码提交都没部署。
  • 分支:codex/first-paint-dead-screen-fallback-20260917git 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.jsNext 自己的 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 从首个提交 4aa0105f2026-07-15)就在

把这些串起来:只要那 25 个 chunk 里有一个没跑起来,用户看到的就正好是这一屏,而且永远不会变——因为所有能救场的逻辑本身就在没跑起来的那堆 JS 里。8 秒超时救不了它自己。

具体是哪一种(设备 Safari < 16.4 / 弱网下某个 chunk 没下全 / 内容拦截器 / Safari 缓存里的坏 chunkchunk 带 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.tslayout 源码里这段脚本不含 =>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.mdBUG-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 的教训)。