Skip to content

Commit c927d54

Browse files
committed
fix(primitives): restore scroll position on modal unlock
1 parent 7deeb27 commit c927d54

2 files changed

Lines changed: 103 additions & 1 deletion

File tree

‎app/src/ui/primitives/Modal.scroll-lock.test.tsx‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,70 @@ describe("⑥ 原值快照属于**第一个**持锁者(弹层交接时不许
200200
expect(overflow(), "快照被后来者改写 ⇒ 宿主页的 'auto' 永久丢失").toBe("auto");
201201
});
202202
});
203+
204+
/* ── T17 / C10#14 追加段:解锁恢复滚动位置(**纯追加**:既有 6 个用例与既有 import 一行未动)──────
205+
* 判据形态(C15「只能登记」边界):jsdom **不做布局** ⇒「恢复后用户看见的位置对不对」**不可验**;
206+
* 本段只钉两件**可观测**的事,**不作**"已在浏览器验证滚动恢复"的声称:① 两条**纯函数**的契约;
207+
* ② **接线**:加锁时读宿主、解锁写回**快照**(不是当前值),且快照归**第一个**持锁者。
208+
* 宿主两条腿都覆盖(探针实测:jsdom 30 **没有** `document.scrollingElement` ⇒ 天然只走兜底腿):
209+
* 主腿 = 注入的假宿主(`Object.defineProperty`,仓库既有注入先例);兜底腿 = jsdom 的真 `body`
210+
* (它**会存** `scrollTop`:探针实测写 251 读回 251 —— 是属性存储,**不是**布局)。
211+
*/
212+
import { restoreScroll, saveScroll } from "./Modal"; // 独立成行:既有那行 import 保持逐字原样
213+
214+
/** 假滚动宿主:`scrollTop` 是唯一契约面;`reads` 计读、`writes` 记写、`jump` = 外部挪动(不计写) */
215+
function fakeHost(initial: number): { host: { scrollTop: number }; reads: () => number; writes: () => number[]; jump: (v: number) => void } {
216+
let value = initial;
217+
let reads = 0;
218+
const writes: number[] = [];
219+
const host = {
220+
get scrollTop(): number { reads += 1; return value; },
221+
set scrollTop(v: number) { writes.push(v); value = v; },
222+
};
223+
return { host, reads: () => reads, writes: () => writes, jump: (v) => { value = v; } };
224+
}
225+
226+
describe("⑦ 解锁时恢复滚动位置(T17 / C10#14;规格 §7.3 第 2 条同族)", () => {
227+
afterEach(() => {
228+
delete (document as unknown as { scrollingElement?: unknown }).scrollingElement;
229+
document.body.scrollTop = 0;
230+
});
231+
232+
it("纯函数契约:saveScroll 读宿主并原样返回;restoreScroll 写宿主(含 0 与覆盖)", () => {
233+
const { host, reads, writes } = fakeHost(250);
234+
expect(saveScroll(host), "保存 = 原样读回宿主的 scrollTop").toBe(250);
235+
expect(reads(), "saveScroll 必须真的读了宿主(否则它可以返回任何常量)").toBeGreaterThan(0);
236+
restoreScroll(host, 120);
237+
expect(host.scrollTop, "计划逐字的样例:restoreScroll(fakeHost, 120) ⇒ 120").toBe(120);
238+
restoreScroll(host, 0);
239+
expect(host.scrollTop, "恢复 0 也是合法写(宿主本来就可能在顶部)").toBe(0);
240+
expect(writes(), "两次恢复各写一次,写的都是传进来的值").toEqual([120, 0]);
241+
});
242+
243+
it("接线主腿:加锁时读 scrollingElement;解锁写回**快照**(期间宿主被挪动 ⇒ 仍回快照)", () => {
244+
vi.useFakeTimers();
245+
const { host, reads, writes, jump } = fakeHost(250);
246+
Object.defineProperty(document, "scrollingElement", { configurable: true, get: () => host });
247+
const { rerender } = render(<Host open />);
248+
tick(0);
249+
expect(reads(), "前提:加锁时确实读了这个宿主(主腿优先于兜底腿)").toBeGreaterThan(0);
250+
jump(999); // 弹层期间宿主被别的东西挪动过
251+
closeAndSettle(rerender, <Host open={false} />);
252+
expect(writes(), "解锁时必须写回**打开前的快照**,不是当前值").toContain(250);
253+
expect(host.scrollTop, "恢复后宿主回到打开前的位置").toBe(250);
254+
});
255+
256+
it("接线兜底腿 + 快照归属:无 scrollingElement ⇒ 写 document.body;关内层不恢复、关外层才恢复", () => {
257+
vi.useFakeTimers();
258+
document.body.scrollTop = 300; // 打开前"页面已滚了 300"
259+
const { rerender } = render(<Nested outer={false} inner={false} />);
260+
tick(0);
261+
rerender(<Nested outer inner />);
262+
tick(0);
263+
document.body.scrollTop = 888; // 期间被挪动
264+
closeAndSettle(rerender, <Nested outer inner={false} />, "inner");
265+
expect(document.body.scrollTop, "内层关闭时外层还在 ⇒ 不许恢复(快照归第一个持锁者)").toBe(888);
266+
closeAndSettle(rerender, <Nested outer={false} inner={false} />, "outer");
267+
expect(document.body.scrollTop, "最后一层关闭必须写回打开前的快照").toBe(300);
268+
});
269+
});

‎app/src/ui/primitives/Modal.tsx‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
* 引用计数 + 原值快照,见 `useBodyScrollLock`;未挂载时**不碰** `document.body` 的样式。
3636
* ⑥ **不消费 `isImeComposing`**:计划 Task 7 Step 1 明确"本批只建不接"—— 需要 IME 守卫的是
3737
* 「Enter 提交」,那是调用点的动作(`Modal` 自己不定义提交)。
38+
* ⑦ **解锁时恢复滚动位置**(批 5 T17 / C10#14;与规格 §7.3 第 2 条「重挂载恢复 `scrollTop`」同族):
39+
* 快照在**加锁那一刻**取(归属 = 第一个持锁者),**最后一个持锁者释放时**写回;两条纯函数
40+
* `saveScroll` / `restoreScroll` 导出给测试。⚠️ **jsdom 不做布局** ⇒ 只到"属性级可观测"(见未验证)。
3841
*/
3942
import { createContext, useContext, useEffect, useId, useRef } from "react";
4043
import type { ReactElement, ReactNode } from "react";
@@ -94,6 +97,29 @@ function isInnermost(entry: { depth: number }): boolean {
9497
const scrollLockOwners = new Set<object>();
9598
/** 首个持锁者记下的**原值快照**(`null` = 当前没持锁)。恢复成 `""` 会抹掉宿主页设过的 overflow */
9699
let savedBodyOverflow: string | null = null;
100+
/** 首个持锁者记下的**滚动位置快照**(`null` = 当前没持锁);与 `savedBodyOverflow` 同源同时刻取 */
101+
let savedScrollTop: number | null = null;
102+
103+
/**
104+
* 视口滚动宿主:`scrollingElement` 是规范里"能滚视口的那个元素"(标准模式 `html` / quirks `body`)——
105+
* 写死任一个都会在另一半场景里写到不该写的元素上(症状 = 解锁后跳回 0,比"不恢复"更难查)。
106+
* 兜底 `body` 是必需的:jsdom 30 **没有** `scrollingElement`(实测 `undefined`)⇒ 没有它,
107+
* 本能力的接线在测试环境里不可观测。
108+
*/
109+
function scrollHostOf(): { scrollTop: number } | null {
110+
if (typeof document === "undefined") return null;
111+
return document.scrollingElement ?? document.body ?? null;
112+
}
113+
114+
/** 记录滚动位置(**纯函数**:只读宿主、原样返回快照)——导出给测试(C10#14 的判据形态) */
115+
export function saveScroll(host: { scrollTop: number }): number {
116+
return host.scrollTop;
117+
}
118+
119+
/** 恢复滚动位置(**纯函数**:只写宿主;不钳制、不分支 ⇒ 契约是全函数、无隐藏语义)——导出给测试 */
120+
export function restoreScroll(host: { scrollTop: number }, saved: number): void {
121+
host.scrollTop = saved;
122+
}
97123

98124
/**
99125
* body 滚动锁(批 4 T2;B6 缺口 A:20 个弹层共用 ⇒ 锁在原语里,调用点零改动)。
@@ -105,6 +131,8 @@ let savedBodyOverflow: string | null = null;
105131
* 提前解锁等于"弹层还在、背景却能滚"(与 §5.2 第 2 条「退场相位必须禁指针事件」同源)。
106132
* Why 单个 effect:加锁与解锁都在同一个 effect 的 body/cleanup 里 —— 拆成两个的话,退场中
107133
* `open` 反向回到 `true` 时 cleanup 会先解锁、再(因依赖未变而)不加回来,锁就永久丢了。
134+
* Why 滚动快照与 `overflow` **同源同时刻**(批 5 T17 / C10#14):两者都是"加锁前的宿主状态",归属
135+
* 必须一致 —— 放在别的 effect 里,嵌套交接时两个快照会来自不同时刻(写回一个从未存在过的组合)。
108136
*/
109137
function useBodyScrollLock(locked: boolean): void {
110138
const ownerRef = useRef<object>({});
@@ -113,14 +141,21 @@ function useBodyScrollLock(locked: boolean): void {
113141
if (typeof document === "undefined") return;
114142
const owner = ownerRef.current;
115143
if (!locked) return;
116-
if (scrollLockOwners.size === 0) savedBodyOverflow = document.body.style.overflow;
144+
if (scrollLockOwners.size === 0) {
145+
savedBodyOverflow = document.body.style.overflow;
146+
const host = scrollHostOf();
147+
savedScrollTop = host ? saveScroll(host) : null; // 加锁时记录(快照归属 = 第一个持锁者)
148+
}
117149
scrollLockOwners.add(owner);
118150
document.body.style.overflow = "hidden";
119151
return () => {
120152
scrollLockOwners.delete(owner);
121153
if (scrollLockOwners.size > 0) return;
122154
document.body.style.overflow = savedBodyOverflow ?? "";
123155
savedBodyOverflow = null;
156+
const host = scrollHostOf();
157+
if (host && savedScrollTop !== null) restoreScroll(host, savedScrollTop); // 解锁时恢复(写回快照)
158+
savedScrollTop = null;
124159
};
125160
}, [locked]);
126161
}

0 commit comments

Comments
 (0)