Skip to content

refactor(virtual): 滚动写入事务化 + origin 锚定,根治长会话滚动空白带 - #607

Merged
su-fen merged 6 commits into
mainfrom
refactor/transcript-scroll-origin-anchoring
Aug 23, 2026
Merged

refactor(virtual): 滚动写入事务化 + origin 锚定,根治长会话滚动空白带#607
su-fen merged 6 commits into
mainfrom
refactor/transcript-scroll-origin-anchoring

Conversation

@su-fen

@su-fen su-fen commented Aug 23, 2026

Copy link
Copy Markdown
Member

Closes #606

概要

  • 收编 virtual-core fork 为 workspace 包(crates/virtual-core,经 pnpm overrides 接管 @tanstack/virtual-core 解析),核心滚动逻辑纳入本仓库源码管理、类型检查与测试门禁;
  • Phase 1 写入事务化:程序化 scrollTop 写入后下一帧读回校验,被 WKWebView 吞掉的写入立即回滚镜像、采用 DOM 真值,消除 scrollOffset 镜像与真实滚动位置的持久发散(空白带的直接成因);
  • Phase 2 origin 锚定模式:视口上方的高度修正与 prepend 锚定吸收进布局原点偏移(originOffset),滚动进行中零 scrollTop 写入、可见行位置零移动;债务在滚动空闲、超额或接近顶部时以单次校验写入结算。iOS 手势期推迟结算,避免与动量滚动竞态;
  • Phase 3 纵深防御:按滚动方向 480px 方向性 overscan、行高测量 localStorage 跨重启持久化(紧凑 [key, size] 格式)、dev 模式镜像不变量断言(节流 1s);
  • GUI 与 WebUI 会话列表统一启用 scrollAnchoring: "origin" + 方向性 overscan + 测量持久化。

测试

  • 新增 virtual-core 回归测试 14 项:吞写自愈、回声消费、origin 吸收/补偿策略门控/prepend 锚定/空闲与强制 rebase/iOS 手势期推迟、方向性 overscan 等;
  • 新增测量 LRU 持久化测试:跨实例往返、布局键门控、紧凑存储结构、损坏负载与配额超限降级;
  • scripts/check.mjs 门禁 14/14 通过;GUI 前端测试 2492 项、WebUI 631 项全部通过。

验证清单

  • macOS GUI(WKWebView)长会话滚轮快速滚动,不再出现空白带
  • WebUI 桌面浏览器同场景验证
  • iOS Safari 触摸/动量滚动无跳动
  • 窗口高度/宽度变化后楼层导航与滚动跟随正常

Made with Cursor

su-fen and others added 3 commits August 23, 2026 22:47
滚动位置回归 DOM 单一事实源,根治"滚轮滑动出现空白、再滚一下才恢复":

- 收编 @tanstack/virtual-core@3.17.4 为 workspace 包(pnpm override
  workspace:*),源码/类型/测试就地管理,registry 的 react-virtual 包
  透明复用;
- Phase 1 写入事务化:每次程序化 scrollTo 一帧后读回校验,被合成器
  吞掉的写入以 DOM 真值回滚镜像并重算区间(WKWebView 滚轮手势期间
  主线程写入会被静默拒绝,镜像偏离导致渲染窗口错位、视口顶部露白);
- Phase 2 origin 偏移锚定:视口上方估高修正与 prepend 键锚定改为吸收
  进布局原点,可见行 DOM 位置零移动、滚动中零写入,债务在空闲/超限/
  近顶时由单次受校验写入结算;
- Phase 3 方向性像素 overscan(粘滞于最近滚动方向,免除落定churn)
  与 dev 态镜像-DOM 不变量断言;
- 13 个核心回归测试覆盖吞写自愈、origin 吸收、rebase 触发与自愈闭环。

Co-authored-by: Cursor <cursoragent@cursor.com>
- GUI/WebUI 会话虚拟列表启用 scrollAnchoring=origin(滚动中零程序化
  写入)与 480px 方向性 overscan(合成器先行滚动时露出预渲染内容而非
  空白);
- 行高测量 LRU 支持 localStorage 持久化(按宿主命名空间),重访会话跨
  重启直接以精确行高布局,消解首滚估高误差这一补偿源头;损坏/超额存
  储静默降级为纯内存,新增持久化回归测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
iOS 动量滚动期间 nearTop/overCap 强制 rebase 会先平移布局,
而配套的 scrollTop 写入被 iOS 延迟分支吞掉,布局与镜像短暂错位
并产生二次跳动;改为手势完全落定后再 rebase(touchend 宽限计时
器到期时补触发一次)。持久化层只存 [key, size] 二元组,localStorage
配额余量约提升 4 倍。

Co-authored-by: Cursor <cursoragent@cursor.com>
@StackCairn
StackCairn marked this pull request as draft August 23, 2026 14:48
@github-actions

Copy link
Copy Markdown
Contributor

PR governance checks failed — this PR has been converted to draft.

  • UI change without screenshots: this PR modifies frontend code. Please add before/after screenshots or a recording under "Screenshots / preview" in the PR body.

Fix the items above, then click Ready for review to re-run the checks.

@su-fen su-fen added the governance-exempt Skip PR governance checks label Aug 23, 2026
@su-fen
su-fen marked this pull request as ready for review August 23, 2026 15:05
su-fen and others added 2 commits August 23, 2026 23:09
@tanstack/virtual-core 经 pnpm overrides 指向 workspace 后,
镜像内缺少该包导致 react-virtual 的类型再导出全部失效,
Gateway Docker Smoke 在 tsc 阶段失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
复审发现 origin 锚定在目标平台上留有一个会复现 #606 的洞:
maybeRebaseOrigin 只对 iOS 推迟手势期结算,macOS WKWebView 在
nearTop/overCap 强制路径下仍会滚动中写 scrollTop;写入被合成器
吞掉时 verifyScrollWriteLanded 只回滚 scrollOffset 镜像,已平移
的布局不回滚——可见行相对视口跳 |debt| 像素,先露白一帧再错位。

- 手势期强制 rebase 的推迟从 isIOSWebKit 扩大到整个 WebKit 家族
  (macOS WKWebView/桌面 Safari 同样会吞主线程滚动写入);落定后
  的 rebase 写入能可靠落地,视觉上是无操作。Blink/Gecko 尊重手势
  期写入,强制路径保持原行为;
- rebase 改为事务:记录结算的债务与写前偏移,滚动事件回声或校验
  帧确认视口实际位移后结清;未被确认的部分回归 originOffset,
  布局与镜像在同一趟内一起回滚,吞写/截断退化为"缩小的 rebase"
  而非跳动,债务留待下一个安全时机结算;
- 新增回归测试:WebKit 手势期推迟、滚动中吞写整帧回滚+重试自愈,
  并按新契约收紧原吞写自愈测试(断言行位置逐像素不动)。

另修复两处持久化边角:
- 超过 5000 行的快照此前直接跳过 localStorage 写入,旧的过期快照
  会存活到下次重启被还原;改为写入时过滤超额条目,顺带清掉陈旧
  副本(新增回归测试);
- WebUI 持久化 layout key 补上行模型版本(对齐 GUI 的版本化 key),
  避免行结构跨版本变化后错误行高穿透刷新继续污染布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
@su-fen

su-fen commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

对本 PR 做了一轮深度复审(结合收编源码逐段核对 + 可控 harness 复现),确认了一个会在目标平台上复现 #606 的洞和两处持久化边角问题,已修复并推送 bf78043

已确认并修复

1. macOS WKWebView 上强制 rebase 仍会滚动中写入,吞写后布局不回滚(核心)

  • maybeRebaseOrigin 的手势期推迟只判 isIOSWebKit();桌面 WKWebView 在 nearTop / overCap 强制路径下照样滚动中写 scrollTop——而"手势期主线程写入被合成器静默吞掉"正是本 PR 自己对 长会话滚轮滚动时列表出现空白带,需再滚动一下才恢复 #606 的根因判断,macOS 与 iOS 同属这一行为族。
  • 写入被吞时 verifyScrollWriteLanded 只回滚 scrollOffset 镜像;rebase 已经把 originOffset 清零、布局整体平移了 |debt| 像素,这部分不回滚。结果:先露白一帧,校验帧后可见行相对视口错位 |debt| 像素——跳动,不是自愈。长会话一次手势攒出 >2000px 债务并不难(8 个估高 100/实测 400 的行就够),快速滚向顶部还会命中 nearTop,恰好是 issue 的复现场景。
  • 原有测试 swallowed rebase writes self-heal through write verification 只断言 blankBand === 0,该断言在跳动之后同样成立,锁不住这个回归。

修复(bf780436):

  • 手势期强制 rebase 的推迟从 iOS 扩大到整个 WebKit 家族(新增 isWebKit(),UA 含 AppleWebKit 且排除 Blink/Gecko 马甲)。落定后的写入能可靠落地,而 rebase 是布局平移 + 等量写入同趟生效,落地即视觉无操作,推迟没有体验代价;Blink/Gecko 尊重手势期写入,强制路径在这些引擎上保持原行为。
  • rebase 事务化:记录结算债务与写前偏移;滚动事件回声或校验帧确认视口实际位移后结清,未被确认的部分回归 originOffset,布局与镜像同一趟回滚。吞写/截断退化为"缩小的 rebase"而非跳动,债务留待下一个安全时机。滚动事件通道只结清不回滚(避免把并发的用户位移误判为吞写,造成一帧"内容冻结");回滚只走校验帧的干净 DOM 读回。
  • 新增/收紧测试 3 项:WebKit 手势期推迟(Safari UA)、滚动中吞写整帧回滚 + 重试自愈、原吞写测试改为逐像素断言行位置不动。

2. 超大会话跳过持久化后遗留过期快照

measurements.length > 5000save() 直接跳过 writePersistedEntries,localStorage 里同会话的旧快照原样存活,重启后以同一 layout key 被还原——喂给布局的是过期行高,反而制造本 PR 想消除的补偿源。改为写入时过滤超额条目:内存照常服务,持久层顺带清掉陈旧副本。附回归测试。

3. WebUI 持久化 layout key 缺行模型版本

GUI 的 key 带 assistant-activity-v2,WebUI 只有 viewport:contentWidth。快照进入 localStorage 后寿命跨越版本发布,行模型/估高语义一变,错误行高会穿透刷新继续污染布局。已对齐 GUI 加版本段(gateway-rows-v1)。

核查后降级(不改代码)

  • 方向性 overscan 与 GUI 成本预算 range extractor 的叠加:复核后确认 480px 是像素上界,最多多挂 1~2 个大行,符合成本预算的初衷,保持原样。
  • getMeasurementsoriginOffset 的接入、prepend 锚定吸收、iOS 触摸窗口清理:逐段核对无误。

验证

  • typecheck:virtual-core 通过;virtual-core 测试 16/16(含新增 3 项);
  • typecheck:ui 通过;GUI 前端测试 2493 项、WebUI 631 项全部通过;lint:ui/gui/webui 干净;
  • WebUI tsc 通过(本地 vite 阶段因环境缺 @svgr/core 失败,与改动无关,以 CI 为准)。

仍建议合入前完成

  • PR 描述中的真机验证清单(macOS GUI 长会话快滚、WebUI、iOS 动量滚动、resize 后楼层导航)仍未勾选。这是合成器时序问题,单测通过不能替代真机;建议附一段 macOS 快滚录像。
  • fork 维护性:建议在 crates/virtual-core 里补一份对上游 3.17.4 的差异说明(改了什么、为什么),方便未来跟进上游版本。

unplugin-icons 经 local-pkg 的 import('@svgr/core') 编译 ~icons/*。
Node ESM 从 local-pkg 自身路径解析该包名,pnpm 又未把它提升到仓库
根 node_modules,Vite 8 worker 因此报 ERR_MODULE_NOT_FOUND。

- 用 packageExtensions 把 @svgr/core / plugin-jsx 挂到 local-pkg 旁;
- .npmrc 提升 @svgr/* 供向上查找兜底;
- overrides 同步到 pnpm-workspace.yaml,避免 pnpm 11 忽略
  package.json#pnpm 后丢掉 virtual-core workspace 覆盖。

Co-authored-by: Cursor <cursoragent@cursor.com>
@su-fen
su-fen merged commit e6bb139 into main Aug 23, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

governance-exempt Skip PR governance checks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

长会话滚轮滚动时列表出现空白带,需再滚动一下才恢复

1 participant