# PROGRESS · 滚动锚两处老问题(2026-09-26) 任务书:`TASK-scroll-anchor-hook-fixes-20260926.md`。执行方:Claude 子代理(直接执行模式)。分支 `codex/scroll-anchor-hook-fixes-20260926`,worktree `.worktrees/scroll-anchor-hook-fixes-20260926`,本地提交未推送。 ## 基线 - `origin/staging` = `509987b9`(BUG-1042 `080ea5ca` 已合入)。 - 基线全量前端测试(Node 20.19.2):3961 条,pass 3873 / fail 61(均为环境缺口:Node 20 `mock.module` 的 `ERR_MODULE_NOT_FOUND @/…`、无 Docker)。 - 基线 rootMainFiles gzip 130933 B;`index.html` 引用的全部 js/css 合计 656748 B。 - BUG 编号:沿用 BUG-1042 单已建的 BUG-1043、BUG-1044,本单不新增编号。 ## 先复现(真实 React 生命周期测试) 新增 `frontend/tests/conversation-scroll-anchor-lifecycle.test.tsx`,用仓内 `react-client-lifecycle-test-support.ts`(`createRoot` + `act`,effect / ref 都是 React 自己的),宿主节点只伪造几何(scrollTop 夹紧、scrollHeight、clientHeight)、事件表、ResizeObserver,以及「异步移动后再发 scroll」的平滑 `scrollTo`。测试组件照 `page.tsx` 的形状:hook 在闸门之上每次渲染都调用,加载时只渲染 `.app-loading`,揭幕后才渲染 `.conversation`。 对改前 hook 跑:9 条里 6 条失败(BUG-1043 三条:揭幕后 scroll 监听 0;点「跳到最新」后长大不跟随;容器重挂载后旧元素仍挂着监听。BUG-1044 三条:静止 94px 时被拉到底;滚轮到底后跟随断言因已提前贴底而位置不符;点消息内按钮后被拉到底)。「触摸上拉不跟随」「active 关开」「滚动键过滤」三条改前也通过(保持语义用)。改后 9/9 通过。 ## 根因(均已证实) | BUG | 根因 | | --- | --- | | 1043 | 两个 effect 依赖 `[active, container, resetKey]`。加载闸门期间容器为 null,effect 早退;揭幕那次提交三个依赖都没变,effect 不重跑 → scroll 监听、ResizeObserver、「打开会话先落底」都没发生。浏览器插桩:改前监听 0 / 观察 0 / 打开后距底 1819px。 | | 1044 | 钉顶后 `measure()` 在任何 scroll 事件里只要距底 ≤ 96 就解除 `holdUnpin` 并翻回贴底。钉顶平滑滚动自己的 scroll 事件落定时校正面距底 94(逐 100ms 采样:…99 → 94 后下一帧跳到底)。向上的非用户滚动也会解除 `holdUnpin`,是同类路径。 | ## 改动 | 文件 | 内容 | | --- | --- | | `frontend/src/hooks/use-conversation-scroll-anchor.ts` | D1:原两个 `[active, container, resetKey]` effect 合为一个**每次提交后运行**的挂载 effect,比对 `container.current` / `active` / `resetKey` 与上次挂载记录,未变即空操作;变了先卸下旧挂载,再对当前元素挂 scroll 监听(`watchAnchor`)与 ResizeObserver + MutationObserver 跟随(`followContent`),两段内部逻辑与原来逐行一致;另有空依赖 effect 在卸载时清理。D2:钉顶期间 `measure()` 只有在 1 秒内出现过读者手势(容器上的 `wheel` / `touchmove` / 按在容器本身即滚动条的 `pointerdown`,window 上文本框与按钮之外的滚动键)时才解除 `holdUnpin`,之后照旧由 `nextAnchorState` 判断;`pinLatestTurn` 钉顶时清掉手势时间戳。新导出 `conversationGestureWindowMs`、`isScrollKeyGesture`。签名不变。 | | 调用处 | **未改**(`page.tsx`、`rectification-agentic-chat.tsx` 一行未动)。不用 callback ref 的原因:两处都另用同一个 `RefObject`,校正面组件本轮由并行任务在改;也不用「effect 里 setState 记下元素」,会触发 react-hooks 编译器规则。 | | `frontend/tests/conversation-scroll-anchor-lifecycle.test.tsx` | 新增 9 条(见上)。 | | `frontend/DESIGN.md` | Answer start anchor:新增 **Release** 条目(只有读者滚动解除钉顶,94px 静止位置不算回到底部);**History** 条目补「页面加载时的第一个会话同样落底、hook 随容器元素挂载」。 | | 记录 | `docs/BUG_HISTORY.md`(BUG-1043 / 1044 根因、修复、验证,状态保持 investigating 等部署)、`CHANGELOG.md`、`docs/testing/scroll-anchor-hook-fixes-20260926.md` + 4 张截图、`docs/tasks/README.md`。 | ### 改动既有断言 无。BUG-930 / 931 / 932 / 1042 的全部既有断言(`chat-notice-and-scroll-contract.test.ts` 的源码合同与假 DOM 行为、`starter-questions.test.ts`「Follow 段在 `if (!active || !element) return;` 之后才写 scrollTop」、`rectification-agentic-entry` / `rectification-timeline` / `rectification-mobile-timeline-readout` / `rectification-answer-choice` 对 hook 的断言)原样通过,一条未改。 ## 测试 | 项 | 结果 | | --- | --- | | `tsc --noEmit` | 0 错 | | `npm run lint` | 0 error(126 warning,与基线相同,均为既有) | | 全量 `npm test` | 3970 条,pass 3882 / fail 61 | | 失败名单 vs 基线 | 逐条一致(61 = 61,diff 为空),新增失败 0 | | 测试名单 vs 基线 | +9 新名(本单新增),消失 0 | | `npm run build -- --webpack` | 通过;`/`、`/_not-found`、`/chart`、`/ephemeris`、`/people` 均 ○ Static | | gzip | rootMainFiles 130933 → 130933 B(0);`index.html` 引用的 js/css 合计 656748 → 657114 B(+366 B,+0.06%) | 构建经 node_modules 软链在 `frontend/frontend/` 下生成的杂散目录已删除,未提交。 ## 真实浏览器验证 环境:本地 `next build --webpack` + `next start`(:3471 本分支;:3461 为 BUG-1042 工作树的构建,hook 与 `origin/staging` 相同,作「改前」),无头 Chrome 151(`--headless=new --disable-dev-shm-usage`),CDP `Fetch` 拦截 `/api/*` 回虚构数据(5 轮虚构历史;普通咨询 `/api/consult` 回纯文本,校正 `/api/rectification/agent` 回 ndjson)。输入框用 `Input.insertText` + Enter 真发问;滚轮用 `Input.dispatchMouseEvent mouseWheel`,触摸用 `Input.dispatchTouchEvent`。给 `EventTarget.addEventListener` / `ResizeObserver.observe` 打点计数 `.conversation` 上的监听。脚本在执行方 scratchpad `sa/sa.mjs`(由 BUG-1042 的 `gap.mjs` 改写),未入库。 「开头距顶」= 本轮用户行顶边 − 滚动容器顶边;「距底」= scrollHeight − scrollTop − clientHeight。 ### (a) 普通咨询,直接打开已有会话(BUG-1043),375 宽 | 进入方式 | 版本 | 监听 / 观察 | 打开后距底 | 短回答结算后「跳到最新」 | 开头距顶 | | --- | --- | --- | --- | --- | --- | | `?c=` 直接打开 | 改前 | 0 / 0 | 1819 | **出现** | 14 | | `?c=` 直接打开 | 改后 | 1 / 1 | 0 | 不出现 | 11 | | 打开后刷新 | 改前 | 0 / 0 | 1819 | **出现**(上下滚动后仍在) | 11 | | 打开后刷新 | 改后 | 1 / 1 | 0 | 不出现(上下滚动后仍不出现) | 13 | | `?new=1` 再点侧栏 | 改后 | 1 / 1 | 0 | 不出现 | 10 | 长回答(14 段),`?c=` 直接打开: | 宽度 | 版本 | 结算后 | 点「跳到最新」后 | 再插入 400px 后距底 | | --- | --- | --- | --- | --- | | 375 | 改前 | 开头距顶 10,按钮出现 | 距底 0,按钮消失 | **400**(不跟随) | | 375 | 改后 | 开头距顶 12,按钮出现 | 距底 0,按钮消失 | 0(跟随) | | 1280 | 改后 | 开头距顶 10,按钮出现 | 距底 0,按钮消失 | 0(跟随) | ### (b) 生时校正长回答(BUG-1044),`?c=` 打开 | 宽度 | 版本 | 结算后开头距顶 / 距底 | 2 秒后 | 无手势 scroll 事件 + 插入 400px | 用户滚轮到底后 | 再插入 400px 后距底 | | --- | --- | --- | --- | --- | --- | --- | | 375 | 改前 | **−1323 / 0**(被拉到底) | 同 | 距底 0(贴底) | — | 0 | | 375 | 改后 | 10 / 1333,按钮出现 | 10 | 开头仍 10,距底 1737 | 距底 0,按钮消失 | 0(恢复跟随) | | 375 触摸 | 改后 | 10 / 1333 | 10 | 开头仍 10 | 触摸拖到底,距底 0 | 0(恢复跟随) | | 1280 | 改前 | **−482 / 0** | 同 | 0 | — | 0 | | 1280 | 改后 | 10 / 492,按钮出现 | 10 | 开头仍 10 | 距底 0 | 0(恢复跟随) | 逐 100ms 采样(375,scrollTop,距底,开头距顶):改前 `…2014,96,12 → 3349,0,-1323`;改后 `…2011,99,15 → 2016,1333,10` 并保持。校正短回答(375)静止距底 94、开头 10,插入 400px 后开头仍 10(不再因 94 ≤ 96 被判回到底部)。 截图(入库,375 宽):`docs/testing/scroll-anchor-hook-fixes-20260926/consult-reload-short-375-{before,after}.png`(改前短回答下有「跳到最新」)、`rectification-long-375-{before,after}.png`。校正截图底部的「合盘历史暂时无法读取」是虚构数据没回合盘接口所致,与本单无关(BUG-1042 同)。 ## 未做 / 环境缺口 - 登录态 iPhone Safari 真机:留给 `docs/testing/scroll-anchor-hook-fixes-20260926.md`。无头 Chrome 的触摸是 CDP 合成事件,不等于 iOS 的惯性滚动,真机第 6–8 条需要人看。 - 键盘滚动(PageDown / 方向键)只有单测覆盖过滤规则,未在浏览器里实测。 - 校正面「点选项」一轮(无用户行)未在浏览器里单独跑;生命周期测试覆盖的就是无用户行、头即尾的钉顶。 - 未部署、未推送。