Files
Jyotisha/docs/tasks/PROGRESS-scroll-anchor-hook-fixes-20260926.md
T
Jesse_ChenandClaude Opus 5.5 da2613ffd9 fix(chat): attach scroll anchor when the scroller appears; only reader scroll releases a pin (BUG-1043, BUG-1044)
- 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
2026-09-26 10:42:12 +08:00

9.1 KiB
Raw Blame History

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 / 方向键)只有单测覆盖过滤规则,未在浏览器里实测。
  • 校正面「点选项」一轮(无用户行)未在浏览器里单独跑;生命周期测试覆盖的就是无用户行、头即尾的钉顶。
  • 未部署、未推送。