- The anchor listener and follow observer now attach whenever the scroller element itself appears (checked after every commit, no-op unless element, active or resetKey changed). The home page mounts `.conversation` after its loading screen with unchanged active/resetKey, so a directly opened session never got a listener, never landed on its newest content, showed the jump chip under short replies and did not follow after pressing it. - After a pin, geometry no longer releases the hold: only a wheel, touch drag, scroll key or scrollbar press followed by a scroll within 1s does. The rectification pin rests 94px from the bottom, inside the 96px threshold, which dragged long replies to their last line. - Real React lifecycle tests (loading screen -> reveal, 94px rest), DESIGN, BUG history, PROGRESS, CHANGELOG, device checklist and CDP screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
9.1 KiB
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-1042080ea5ca已合入)。- 基线全量前端测试(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 / 方向键)只有单测覆盖过滤规则,未在浏览器里实测。
- 校正面「点选项」一轮(无用户行)未在浏览器里单独跑;生命周期测试覆盖的就是无用户行、头即尾的钉顶。
- 未部署、未推送。