fix(web): 次级页稳定外壳与缓存预取,跨部署导航自愈(BUG-966/967)

This commit is contained in:
jesse-ux
2026-09-18 18:45:48 +08:00
parent dccedf372e
commit 9ccb65b71f
25 changed files with 1066 additions and 160 deletions
+43 -7
View File
@@ -12533,7 +12533,7 @@
## BUG-965 | 侧栏「星盘 / 星历 / 我的报告」点了不跳转,账户入口也没反应
- 状态:investigating(触发条件已由产品复现确认,修复未做
- 状态:resolved(由 BUG-967 修复
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`AppSidebar``NAV_PAGES` 链接与页脚账户入口;staging `877128ce`
@@ -12545,12 +12545,48 @@
3. 链接是真链接:三项导航是 `SidebarMenuLink``next/link``<Link href>``onClick={leaveChat}` 只做 `persistLoginSessionReturn()` 与关抽屉,**没有 `preventDefault`**。
4. 没有覆盖桌面侧栏的 `pointer-events: none``inert``inert` 只出现在移动端抽屉、时间轴折叠体、`SidebarInset``insetInert = modalOpen`,只盖正文区不盖侧栏)。
5. 次级页页脚是 `Link href="/"`(账户菜单只在 `/`),因此「在次级页点头像」应当跳回首页而不是没反应。
- 待取证据:控制台报错原文与 Network 面板是否有 404 的 `/_next/static/**`;点击时所在页面的 URL;该标签页是否在 17:12 部署之前就已打开;硬刷新(Cmd+Shift+R)后是否恢复
- 根因(按复现结论):标签页跨过部署后,客户端导航要拉的构建产物已被新镜像替换(构建 id 变了,旧 chunk / RSC 负载不再存在),路由切换静默失败;已经加载好的对话页因为代码都在内存里,照常工作。Next 16 自带的构建不匹配回退在这次没有把用户带到整页重载,具体是被哪一层吞掉(404 形状、Caddy、还是 RSC 负载不匹配的判定)需要在修复单里查证
- 修复方向:见 `docs/tasks/TASK-secondary-page-entry-20260918.md` BUG-967——客户端用编译期注入的 `NEXT_PUBLIC_GIT_COMMIT``/api/health``deployment.gitCommit` 比对,发现自己是旧版本就在下一次导航时整页重载,而不是静默失败
- 关联记录:BUG-744~746(侧栏统一为 `Link` 跳转)、BUG-926~929(会话列表单一数据源与外壳搬家)、BUG-936(首屏 bundle 未执行)
- 复发自:待确认
- 修复版本:待发布
- 根因:见 BUG-967。标签页跨过部署后,客户端导航要拉的构建产物已被新镜像替换;Next 16.3.1 自带的构建不匹配回退在自托管 + 预取缓存这条路上没有把用户带到整页重载,点击表现为静默无反应。已经加载好的对话页因为代码都在内存里,照常工作
- 修复:BUG-967 的版本比对自愈。旧标签页在下一次站内跳转走 `window.location.assign`
- 验证:见 BUG-967
- 防复发:见 BUG-967。不得再把「刷新就好」当成侧栏无响应的结案。
- 关联记录:BUG-967、BUG-204、BUG-744~746、BUG-926~929、BUG-936
- 复发自:BUG-204 同类(跨发布客户端导航),那次修的是 `deploymentId` / `?dpl=`,在 Vercel 上靠部署路由 404 触发 MPA;本仓是自托管单镜像,那条路径不成立。
- 修复版本:本轮提交
## BUG-966 | 星盘 / 星历 / 我的报告进入时矮等待块推挤成高正文
- 状态:resolved
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`chart-page-view.tsx``ephemeris-page.tsx``personal-report-center.tsx``use-chart-page.ts``SecondaryHeader`
- 用户现象:从侧栏进「星盘 / 星历 / 我的报告」有一瞬间抖动。
- 触发条件:进入这三页,尤其是同一会话里每次进入(没有首屏缓存)。
- 根因:三页各写各的等待布局——先渲染 46px 标题加一行短等待文案,数据到达后整块换成高正文。`SecondaryHeader``note` 未到时不渲染,标题区二次填充。`use-chart-page` 进页才 fetch,零缓存,每次都重放中间态。产品 2026-09-18 选定方案一:消灭中间态,不加 spinner,红线不动。
- 修复:三页共用 `SecondaryPageShell`(标题行高度固定、note 槽占位不塌、内容区撑满剩余视口,等待文案居中在该区)。星盘 / 星历 / 报告首屏进模块级缓存,同一会话再进直接用上次结果并后台静默刷新。侧栏三项在 `pointerenter` / `pointerdown` 预热数据。等待句仍是静态句(「这一张盘还没拿到。」等),没有 spinner / 骨架 / 「正在加载」。
- 验证:`frontend/tests/secondary-page-entry.test.ts`(三页 import 共享外壳、暖缓存第二次不经 null 等待、预取命中同一 inflight、CSS 内容区 min-height:0 且等待居中);既有 `chart-page-view` / `ephemeris-page` / sidebar 合同。浏览器量高度见 `docs/testing/secondary-page-entry-20260918.md`(无 Chrome)。
- 防复发:次级页进入不得再各写一套等待布局。揭幕后不得加 spinner / 骨架 / 「正在加载」。缓存必须模块级,组件 unmount 不能丢掉。
- 关联记录:BUG-716、BUG-717、BUG-745
- 复发自:无
- 修复版本:本轮提交
## BUG-967 | 跨部署旧标签页的客户端导航静默失效
- 状态:resolved
- 首次发现:2026-09-18
- 最近更新:2026-09-18
- 影响面:`AppLink``app-navigation.ts``StaleBuildGuard`、侧栏 `NAV_PAGES` 与次级页账户入口、Web 镜像 `NEXT_PUBLIC_GIT_COMMIT`
- 用户现象:见 BUG-965。
- 触发条件:标签页在一次部署之前打开,部署后不刷新,点站内次级页或账户入口。
- 根因(查证 Next 16.3.1 源码,不得跳过):
1. 不匹配回退写在 `fetchServerResponse`RSC 不是 `text/x-component`、或非 2xx、或 `x-nextjs-deployment-id` / `flightResponse.b``getNavigationBuildId()` 时才 `doMpaNavigation``<Link>` 进页时已经把 `/chart` `/ephemeris` `/reports` 的 Flight 预取进内存;点击走缓存、不再 fetch,这条检查根本不跑。
2. 自托管只有当前镜像。Caddy 对 `/_next/static` 没有改写,只是 `reverse_proxy web:3000``?dpl=` 是 Vercel 按部署分流的参数,在本机被忽略。目标路由在新镜像上仍 200 返回新的 Flight,`!res.ok` 也不成立。
3. `handleHardNavError` / `useNavFailureHandler` 编译期开关 `__NEXT_APP_NAV_FAIL_HANDLING`standalone 默认关。旧 chunk 的 `ChunkLoadError` 若被 router reducer 吃掉,既有 `StaleClientRecovery`(只听 `window.error` / `unhandledrejection`)也看不见。客户端导航于是表现为点击无反应;挂起的 `useTransition` 还能让同一页的账户入口一起没反应。
- 修复:编译期 `NEXT_PUBLIC_GIT_COMMIT`Docker 构建时与 `NEXT_DEPLOYMENT_ID` 同值写入)对照 `/api/health` `.deployment.gitCommit`。页面重新可见时拉一次 health,导航前读这份快照。不一致则 `window.location.assign`,一致则继续客户端路由。health 失败不强制刷新。无轮询、无定时重载。
- 验证:合同测试——版本不一致走 `assign`,一致走传入的 `router.push` / 不 preventDefaulthealth 失败或 commit 为 `unknown` 不强制跳。真机跨部署见 `docs/testing/secondary-page-entry-20260918.md`
- 防复发:站内跨布局跳转走 `AppLink` / `navigateAppPath`,不得假定 Next 的 mismatch fallback 在自托管上会救场。不得用「每次导航都整页重载」或定时刷新糊过去。
- 关联记录:BUG-965、BUG-204
- 复发自:BUG-204
- 修复版本:本轮提交
## BUG-955 | 输入处理器 abort 与「合同未完成」同码,窗口装配失败藏了四周
@@ -0,0 +1,65 @@
# 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 本轮不动。
+1 -1
View File
@@ -142,7 +142,7 @@
| `TASK-contract-degraded-pass4-20260918.md` | `PROGRESS-contract-degraded-pass4-20260918.md` | 验收 review:BUG-956 新增的降级交付路径绕过 Pass 4,保证句原样送达(BUG-959);`uncontractedText` 跨 attempt 不清零,同一轮正文说两遍(960);降级后还空跑一轮 compose(961)。基线 `877128ce` | 待验收 | `e07d5190` |
| `TASK-window-consult-contract-20260918.md` §5 | `PROGRESS-window-consult-precompute-20260918.md` | BUG-957:申报时段计算改为服务端预跑并走同请求缓存。产品确认 BUG-954 已在 staging 验证,本轮单独做。不得 `toolChoice: "required"`,本命不预跑 | 待验收 | `efd40d43` |
| `TASK-chat-markdown-list-20260918.md` | `PROGRESS-chat-markdown-list-20260918.md` | 真机排版反馈:聊天正文列表**没有项目符号**Tailwind v4 preflight 清了 `list-style``.markdown-list` 没恢复,BUG-962);`promoteDefinitionLists` 把四标题口语体的散文误判成并列项,三段正文被改写成列表(BUG-963,判据太松,收紧而非删除——BUG-356 的场景要留);思考条与正文之间实测 56px=报告 grid gap 24 + 首标题 margin-top 32,后者是被特指度压掉的「首元素清零」规则(BUG-964)。基线 `ea0280c1` | 待验收 | `8b102d52` |
| `TASK-secondary-page-entry-20260918.md` | | 真机反馈:星盘 / 星历 / 报告进入时抖一下——三页都是「矮的等待文案块 → 高的正文块」一次推挤,且 `use-chart-page` 无缓存所以每次进入都抖(BUG-966)。产品已拍板方案一:统一外壳 + 缓存 + 预取消灭中间态,**不加 spinner**,红线不动。另含 BUG-967:标签页跨过部署后客户端导航静默失效(BUG-965 已复现确认,刷新即恢复),要按 `NEXT_PUBLIC_GIT_COMMIT``/api/health` 比对自愈。基线 `41902067` | 待领取 | |
| `TASK-secondary-page-entry-20260918.md` | `PROGRESS-secondary-page-entry-20260918.md` | 真机反馈:星盘 / 星历 / 报告进入时抖一下——三页都是「矮的等待文案块 → 高的正文块」一次推挤,且 `use-chart-page` 无缓存所以每次进入都抖(BUG-966)。产品已拍板方案一:统一外壳 + 缓存 + 预取消灭中间态,**不加 spinner**,红线不动。另含 BUG-967:标签页跨过部署后客户端导航静默失效(BUG-965 已复现确认,刷新即恢复),要按 `NEXT_PUBLIC_GIT_COMMIT``/api/health` 比对自愈。基线 `41902067` | 待验收 | `4c69a3dc` |
| `TASK-account-dialog-inert-20260918.md` | — | 真机:账户弹窗打开后整个弹窗点不动、退出登录做不了,刷新依旧——`e4e73f56``SidebarInset` 搬进 layout 后 `inert={modalOpen}` 罩住了没有 portal 的 `AccountDialogOverlay`(BUG-968P0,代码级确认)。另含 BUG-969:校正「换一件事问」后无下文,服务端已出下一题且快照重算完整,客户端停在开场状态,GET 路由与客户端两端静默,本单只做可观测 + 不静默 + 题干进正文。基线 `1061514f` | 待领取 | — |
| `TASK-first-paint-dead-screen-fallback-20260917.md` | — | 真机:首页永远停在「正在载入账户」,兜底全在没跑起来的 bundle 里(BUG-936 investigating)。根 layout 加与 bundle 无关的内联兜底 + 去掉本仓正则后行断言 | 待领取 | — |
| `TASK-consultation-answer-start-anchor-20260917.md` | `PROGRESS-consultation-answer-start-anchor-20260917.md` | 主会话回答落在结尾:`useConversationScrollAnchor` 是贴底跟随,流式期间视口钉在最后一个字,回答开头滚出视口;改为发送后问题钉顶、回答向下长、长出视口显示「跳到最新」、末尾动态留白;产品追加拍板:校正面同一语义(推翻 BUG-041/048 贴底),本轮开头 = 用户行或新助手行。BUG 段 930 起 | 已验收(经修复单) | `worktree/green-harbor-5be3` |
@@ -0,0 +1,75 @@
# 真机清单 · 次级页进入不再推挤 + 跨部署导航自愈(2026-09-18
对应 `TASK-secondary-page-entry-20260918.md``PROGRESS-secondary-page-entry-20260918.md`、BUG-965 / 966 / 967。
自动化做不到的部分:需要**浏览器 + 已登录账号**。执行环境无 Chrome、无登录态,因此这些是环境缺口,不是「已通过」。
准备:桌面 Chrome,宽度 ≥ 1024px,已登录。DevTools → Network 勾上 `Preserve log`
## A · 进入三页时外壳高度不变(BUG-966)
1. 停在 `/`。Console 里先贴:
```js
(() => {
const measure = () => {
const node = document.querySelector(".secondary-page");
if (!node) return null;
const r = node.getBoundingClientRect();
return { h: Math.round(r.height), top: Math.round(r.top) };
};
const log = [];
const obs = new ResizeObserver(() => log.push({ t: performance.now(), ...measure() }));
const start = () => {
const node = document.querySelector(".secondary-page");
if (!node) { requestAnimationFrame(start); return; }
obs.observe(node);
log.push({ t: performance.now(), ...measure(), event: "first" });
};
window.__secondaryPageLog = log;
window.__secondaryPageStop = () => { obs.disconnect(); console.table(log); };
document.addEventListener("click", (event) => {
const a = event.target.closest("a[href='/chart'], a[href='/ephemeris'], a[href='/reports']");
if (a) requestAnimationFrame(start);
}, true);
console.log("ready: click 星盘 / 星历 / 我的报告, then __secondaryPageStop()");
})();
```
2. 点侧栏「星盘」。内容出现后立刻在 Console 执行 `__secondaryPageStop()`。
- **期望**:表格里所有 `h` 相同(等待态与内容态同高),`top` 不变。
- **不期望**:高度从一行文案跳到整页正文;出现转圈、骨架、「正在加载」。
3. 对「星历」「我的报告」各做一次。三页的等待句可以不同(盘还没拿到 / 星历还没拿到 / 报告列表还没拿到),但都必须写在 `.secondary-page` 里居中,不能是矮块。
## B · 第二次进入没有等待句(BUG-966 缓存)
1. 打开过「星盘」并等到盘出现。
2. 点「新建对话」回 `/`(不要刷新)。
3. Network Clear,再点「星盘」。
- **期望**:第一帧就是盘(或失败句),**没有**「这一张盘还没拿到。」。Network 里可以有一条后台 `/api/chart-view`(静默刷新)。
4. 「星历」「我的报告」同样:第二次不得再出现各自的等待句。
## C · 悬停会预取数据(BUG-966 预取)
1. 刷新 `/`Network Clear,筛选 `chart-view|ephemeris|reports`。
2. 鼠标移到侧栏「星盘」上,**先不要点**。
- **期望**:出现 `GET /api/chart-view`。对「星历」「我的报告」分别是 `/api/ephemeris`、`/api/reports`。
3. 再点进去。
- **期望**:若预取已完成,进页不再先闪等待句。
## D · 跨部署旧标签页会在下一次跳转整页重载(BUG-965 / 967
这一条必须跨一次真实部署,属环境依赖。
1. 在 staging 打开 `/` 并停留在对话页(不要关标签)。确认对话还能发、还能收。
2. 让负责人发一版新的 staging(或等下一轮门禁部署)。记下新 SHA。
3. **不要刷新**这个旧标签。切到别的应用再切回来(触发 `visibilitychange`)。
4. Network Clear。点侧栏「星盘」。
- **期望**:出现 Type = `document` 的 `/chart` 请求(整页重载),页面打开;不是点击无反应。
- 地址栏最终是 `/chart`,之后再点「星历」应走客户端导航(没有新的 document 请求),因为这时已经是新构建。
5. 另开一个**部署之后**才打开的新标签,点「星盘」。
- **期望**:没有 document 请求(版本一致,仍是客户端路由)。
6. 若 health 接口人为断掉(DevTools Offline 只挡 `/api/health` 不挡其它):
- **期望**:当前标签继续能点、不强制刷新。
失败时记下:点击时的 URL、Console 全文、Network 里 `/_next/static` 与 `/chart?_rsc` 的状态码。不要把用户资料贴进仓库。