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

97 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 / 方向键)只有单测覆盖过滤规则,未在浏览器里实测。
- 校正面「点选项」一轮(无用户行)未在浏览器里单独跑;生命周期测试覆盖的就是无用户行、头即尾的钉顶。
- 未部署、未推送。