# DIR-PICKER-SCROLL —— 选择文件夹 / 选择文件「自动跳顶 / 锁定在最顶」的根因、修法与行为契约 > 适用范围:`dsh-mpkg-wallpaper`(MIT)的设置面板选择器(「背景来源 → 自定义目录 → 浏览…」弹窗、 > 本地壁纸库列表、轮播勾选网格、下拉列表)。撰写日期:**2026-09-17**。 > 对应回归门禁:`tools/dir-picker-test.mjs`(已并入 `tools/check.sh` 第 2 步)。 > 测试台(`vendor-ref/ww-pages/` 的 8901/8902 页面)线的对齐口径见 §5。 --- ## 0. 用户原话(第 13 条) > 「在这个静态页面(8901),包括我说的 8902,这两个页面,壁纸库的选择文件夹的功能,去引用我的上游的 > DSH 插件那样同款的选择文件夹的功能,包括选择文件也是的。但是我这里提醒一个 bug,在 DSH 的插件里 > 一直没修好的一个 bug:**就是在选择文件夹的这个功能里面,鼠标上下滑动的时候,画面有时候会自动弹跳到 > 最顶上,包括有时候会自动锁定到最顶上**。这个 bug 你一直没有修好……」 --- ## 1. 结论(一屏先看) **根因不是某一行的笔误,而是「滚动位置没有任何人负责」+ 三次补救式补偿的叠加。** 逐条判据如下(全部可在本机复跑): | # | 结论 | 判据 / 证据 | 与用户症状的对应 | |---|---|---|---| | **RC-1** | **旧代码里那段"事后补偿"是死代码,一行都没执行过** | `git show HEAD:lib/client.js`:`6447` 声明 `dirScrollRef`(注释写明是 `{anchorIdx, anchorOff}`);**唯一**赋值点在 `6877`:`dirScrollRef.current = { anchorIdx, anchorOff, rows: rows.length }` —— **从不写 `ratio`**;而 `6906-6907` 是 `const ratio = dirScrollRef.current && dirScrollRef.current.ratio; if (ratio === void 0 \|\| ratio === null) return;` ⇒ **恒真早退** ⇒ 其后的锚点补偿(`6919 el.scrollTop += …`)与按比例恢复(`6923 el.scrollTop = Math.round(ratio*max)`)**永不可达** | 「滚动位置没人管」⇒ 只要列表被重建一次,位置就丢(跳到最顶);用户报的"一直没修好"正是因为修的是死代码 | | **RC-2** | **列表容器会被 React 重建 ⇒ 新节点 `scrollTop` 天然 = 0** | ① 目录行的 key 是**裸目录名**(`HEAD:10258 key: sd`),内容变化时行会被错位复用;② 选择器弹窗元素在宿主侧那个**无 key 的巨型 children 数组**里(`HEAD:10197 dirPick ? h(MaskPortal …)`),兄弟条件项增删会让 React 按**下标**重新对齐;任何一处让该节点被换掉,新 `div` 的 `scrollTop` 就是 0 ⇒ 用户看到「弹跳到最顶上」;③ 实测对照见 §3-B(同一模型下旧写法 `178 → 0`,新写法保持 178) | 「有时候会自动弹跳到最顶上」("有时候"= 取决于那一刻有没有兄弟节点/内容变化) | | **RC-3** | **补救式补偿与用户滚动互相打架 ⇒ 表现为「锁定在最顶」** | 旧实现给补偿包了两道时间窗:`HEAD:6898`(用户 600ms 内滚过就放弃恢复)+ `HEAD:6900-6901`(同路径只恢复一次);还监听 `wheel/touchmove/scroll` 打时间戳。这类"猜用户在不在滚"的补丁本身就是打架的证据:一旦它真的执行(例如 `ratio` 将来被写上),`el.scrollTop += …` 会在用户滚动中途把位置**写回**旧锚点 | 「有时候会自动锁定到最顶上」(补偿把位置写回 / 与滚动手势抢同一个滚动容器) | | **RC-4** | **列表没有 `overscroll-behavior: contain` ⇒ 滚轮到边界串联给宿主设置面板** | 旧实现内联样式只有 `{ maxHeight: 240, overflowY: "auto" }`(`HEAD:10264`),整个文件里只有一处 `overscroll-behavior-x: contain`(且是聊天总览区)。列表滑到底后剩余滚轮量交给宿主面板 ⇒「画面」(面板)跟着跳 | 「画面会自动弹跳」的第二个来源(列表本身没动,动的是它背后的面板) | | **RC-5** | **宿主重渲染会把选择器弹窗整棵子树重新挂载 ⇒ 位置归零**(2026-09-17 真机探针定案) | 真机探针(`tools/dir-picker-probe.mjs`,真实 GUI + 真实无头 Firefox)实测:一次普通面板状态变化(给面板输入框派发 `input`)就会在**同一毫秒**产生 `.mpw_mask` 的 `removed` + `added` —— 弹窗(含列表容器)被整棵重建;旧实现下 `scrollTop 1000 → 0`(正是用户报的"跳回最顶")。**只把补偿逻辑修好还不够**:滚动记忆若存在"组件实例内",重挂时实例被卸载 ⇒ 记忆一起丢 ⇒ 恢复逻辑拿到空记忆只能认领 0。**修法:记忆放模块级(活得比 React 树长)** | 「鼠标上下滑动的时候,画面有时候会自动弹跳到最顶上」——"有时候"= 那一刻恰好有面板状态变化(异步探测/提示/进度等都会触发重挂);"锁定在最顶上"= 每次重渲染都把它按回 0 | **已排除 / 已按契约显式规避**(用户列的候选里,这两条在本插件不成立,门禁里仍然断言住,防将来回归): | 候选 | 判定 | 证据 | |---|---|---| | 新节点 `focus()` / `autoFocus` 把容器滚到该节点 | **旧实现不成立、新实现按契约显式规避** | 旧实现:注释剥离后**没有任何 `.focus(` / `autoFocus`**(A5)。新实现按测试台 §10.5:打开时**只**聚焦列表容器一次且 `focus({preventScroll:true})`(A9/B3b),行 `tabindex="-1"` + 行容器 `mousedown` 阻止默认聚焦(A8/B3f)⇒ 任何行都不会成为 `activeElement`,键盘移动也不聚焦行(B3d),因此**不存在**"焦点把某行滚进视野"这条路径 | | 滚动事件里自己写了 `scrollTop = 0` | **不成立** | 旧实现里唯一写 `scrollTop` 的是死代码 RC-1 那两行;新实现**滚动路径只写 ref**(B6:滚动不产生 setState;B8c:不存在把列表写回 0 的写入) | | 注入样式改变了滚动容器(`overflow`/`contain`/`position`) | **部分成立,已纳入修法** | `.mpw_props` 原本已有 `overflow-anchor: none`(2026-09 那条"锚定跳顶"补丁),但**缺** `overscroll-behavior`;`.mpw_tabRow` 的 `transform/will-change` 只影响 fixed 包含块(弹窗已 portal 到 `body`,不受影响) | --- ## 2. 候选根因逐条验证过程(怎么判的) 1. **重建整个列表 ⇒ 滚动位置丢失**:成立。目录列表每次 `setDirSubs(新数组)` 都会让 React 重跑 `.mpw_props` 的子树;行 key 是裸目录名 ⇒ 目录改名/内容变化时按 key 错位复用;容器本身在被 宿主数组下标顺移换掉时是**新节点**。新节点的 `scrollTop` 天然是 0,且旧实现**没有任何机制**把它补回来(RC-1)。 2. **新节点 `focus()` ⇒ 浏览器把容器滚到该节点**:不成立(无 focus 调用;见 §1 排除表)。 3. **滚动事件里 `scrollTop = 0` / 回收顶部**:不成立(无该写入);但**存在同类**:`el.scrollTop += …` 的"锚点补偿"(死代码)与"按 ratio 恢复"(死代码)——修法是**整段删除**,而不是修好它。 4. **虚拟列表 / 过滤后 key 变化导致行复用错位**:成立(key = 裸目录名)。修法:行 key 改为 **完整路径 + `\u0000` + 目录名**,并在门禁里断言"刷新后行节点 uid 不变"(增量更新)。 5. **注入样式改变滚动容器**:部分成立。修法:容器自带 `overscroll-behavior: contain` + `overflow-anchor: none`(两者都在**内联样式**上,不依赖注入的 `