66 lines
5.6 KiB
Markdown
66 lines
5.6 KiB
Markdown
# PROGRESS · 次级页进入时的等待态与抖动(2026-09-18)
|
||
|
||
分支:`codex/secondary-page-entry-20260918`
|
||
基线:`origin/staging` @ `1061514f`(任务书写的 `41902067` 是写单时的 head;本 worktree 按开工指令跟当前 `origin/staging`)
|
||
方案:产品 2026-09-18 选定方案一。方案 B 未做。
|
||
|
||
## BUG-967 调查:Next 16.3.1 的 mismatch fallback 为什么没开火
|
||
|
||
对照 `next@16.3.1` 源码(GitHub tag `v16.3.1`),不是猜的。
|
||
|
||
### Next 自己会在什么时候整页跳
|
||
|
||
`packages/next/src/client/components/router-reducer/fetch-server-response.ts`:
|
||
|
||
1. RSC 响应 `Content-Type` 不是 `text/x-component`(export 模式下还接受 `text/plain`),或 `!res.ok`,或没有 body → `doMpaNavigation`。
|
||
2. 解出 Flight 之后:`(res.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ?? flightResponse.b) !== getNavigationBuildId()` → 同样 MPA。
|
||
3. `app-router.tsx` 看到 `pushRef.mpaNavigation` 才 `location.assign` / `replace`,并 `throw unresolvedThenable` 卡住当前树。
|
||
|
||
另有 `nav-failure-handler.ts` 的 `handleHardNavError`:导航过程中未捕获错误则 `window.location.href = window.next.__pendingUrl`。**整份文件包在 `process.env.__NEXT_APP_NAV_FAIL_HANDLING` 里**,standalone 默认不打开。
|
||
|
||
既有 `StaleClientRecovery` 只在 `window.error` / `unhandledrejection` 文本像 chunk 失败、且 `sessionStorage` 还没记过一次时 `reload()`。router reducer 如果把 rejection 吃掉,它看不见。
|
||
|
||
### 为什么这次三条都不触发
|
||
|
||
1. **预取缓存让检查根本不跑。** 侧栏三项是 `<Link>`。标签页在部署前已经打开对话页时,Next 已经把 `/chart` `/ephemeris` `/reports` 的 Flight 预取进内存。点击走这段缓存,不再 `fetchServerResponse`。构建 id 比对、非 2xx、非 Flight Content-Type,全部不执行。
|
||
2. **自托管没有 Vercel 那种 `?dpl=` 分流。** `next.config.ts` 写了 `deploymentId: process.env.NEXT_DEPLOYMENT_ID`,静态资源带 `?dpl=`。在 Vercel 上这个 query 把请求打到对应部署,旧部署不在了就是 404,于是走条件 1。本仓 Caddy(`deploy/Caddyfile.staging` / `Caddyfile.production.selfhosted`)对 `/_next/static` **没有任何特殊 404 改写**,只 `reverse_proxy web:3000`。主机上只有当前镜像。旧 tab 就算真的去 fetch `/chart`,新服务器仍对这条路径返回 **200 + 新的 Flight**。条件 1 的 `!res.ok` 不成立。BUG-204 当年靠 `deploymentId` 修好的是 Vercel 形状,不是这台 VPS。
|
||
3. **错误回退开关是关的。** 旧 chunk 404 变成 `ChunkLoadError` 时,默认 standalone 不会把它升级成 MPA。点击看起来像没反应。`app-router.tsx` 若已经丢出 `unresolvedThenable` 或 `startTransition` 一直 pending,同一页上的账户按钮(本就不是 Link)也会一起没反应——这与「对话还能发」不矛盾:对话代码在内存里,路由切换被卡住。
|
||
|
||
Caddy 不是元凶:它没有把 404 变成 200 HTML。问题是 **200 的新 Flight** 加上 **根本不再 fetch 的预取缓存**。
|
||
|
||
### 自愈怎么做(才没有废掉正常客户端导航)
|
||
|
||
- 客户端 commit:构建期 `NEXT_PUBLIC_GIT_COMMIT`(Dockerfile 里与 `NEXT_DEPLOYMENT_ID` 同值写入,不改 workflow)。
|
||
- 服务端 commit:已有的 `GET /api/health` → `.deployment.gitCommit`(运行时 `GITHUB_SHA`)。
|
||
- `StaleBuildGuard` 在挂载和 `visibilitychange → visible` 时拉一次 health。不轮询、不定时 reload。
|
||
- 导航前读这份快照。不一致:`window.location.assign`。一致:原样走 `Link` / `router.push`。health 失败或任一侧是空/`unknown`:不强制跳。
|
||
|
||
## BUG-966
|
||
|
||
三页改为 `SecondaryPageShell`。`.secondary-page` 是 chat-panel 第二行(`minmax(0,1fr)`),等待与正文同一格子;等待句居中。`SecondaryHeader reserveNote` 让出生行晚到时标题行不塌。模块级缓存 + 侧栏 `pointerenter`/`pointerdown` 预取。报告列表首屏去掉 `InlineSpinner`「正在读取报告…」,改静态句「报告列表还没拿到。」刷新按钮上的 spinner 仍是用户点的动作,DESIGN 允许。
|
||
|
||
未做方案 B(延迟揭幕)。
|
||
|
||
## 测试
|
||
|
||
| 命令 | 结果 |
|
||
| --- | --- |
|
||
| `npx tsc --noEmit` | 0 错 |
|
||
| `npm run lint` | 0 error(既有 120 warning,本轮文件未新增) |
|
||
| 定向:`secondary-page-entry` / `chart-page-view` / `ephemeris-page` / `sidebar-contract` / `stale-client-recovery` / `personal-report-entry` / `chat-navigation-a11y-contract` / `class-name-definition-contract` | 114 / 114 |
|
||
| `personal-report-view.test.ts` | 通过(详情页仍用 `SecondaryHeader`) |
|
||
| `next build` | compile + tsc 过;收集 `/api/daily-starlanguage` 时 Windows `EPERM` 无法 symlink skill runtime。环境缺口,与本轮无关。`/` Static 与 gzip 未在本机量到。 |
|
||
|
||
无 Chrome:高度真机清单在 `docs/testing/secondary-page-entry-20260918.md`。无登录态:缓存第二次进入与跨部署自愈的浏览器步骤同样写在那里。
|
||
|
||
改过的既有断言:
|
||
|
||
| 测试 | 原值 | 新值 | 原因 |
|
||
| --- | --- | --- | --- |
|
||
| `sidebar-contract` 只读行 / 页脚 | `<Link className="session-main">`、`<Link className="profile-trigger" href="/">` | `AppLink`,class 与 href 不变 | BUG-967 跨部署自愈;只读行仍是链接、页脚仍去 `/` |
|
||
|
||
## 偏离
|
||
|
||
- 任务书写 `app/(secondary)/layout.tsx`,当前树是 `app/(app)/layout.tsx`(会话列表单源之后)。外壳挂在这里,没有把路由组改回去。
|
||
- 报告详情 `/reports/[reportId]` 仍用 `SecondaryHeader` 各阶段一份,不进三页进入抖动的范围;loading.tsx 里的 spinner 本轮不动。
|