yggdrasil/libs/yggdrasil-core/src/hash-scroll.test.ts
xfy f6b7ab4333
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
fix(post): scrollToHash 增加 ResizeObserver 布局稳定期,修正 mermaid 异步渲染导致的锚点落点偏移
直接访问 /post/slug#heading 或刷新该 URL 时,目标标题停在屏幕中间偏下,但点击目录
或标题跳转却正常。根因是时序竞争:

post_content 的 use_effect 同步依次调用 __initMermaid(注册 IntersectionObserver,
异步)→ __scrollToHash。scrollToHash 的"立即 + 两帧"校正只覆盖浏览器原生
fragment-scroll;而 mermaid 懒加载(加载 ~1MB bundle → render → pre.innerHTML=svg)
是数百毫秒的异步链,发生在两帧之后。mermaid 块从源码文本膨胀成 SVG 图时撑高内容,
把目标标题推离已校准的落点。mermaid.ts 渲染完成后无任何落点修正。点击目录正常是
因为那时 mermaid 已渲染、布局稳定。

解法:scrollToHash 末尾新增 stabilizeScrollOnResize——在 STABILIZE_WINDOW_MS(2s) 内
用 ResizeObserver 观察 .post-content 容器,检测到布局变化即用 rAF 合并重新校正一次
落点。与 mermaid 解耦,同时覆盖图片/字体等所有异步位移源。用户主动滚动(wheel/
touchmove/keydown)或超时后自动停止,不与交互打架。重入安全:主题切换重跑 use_effect
时先 dispose 上一次稳定器,避免泄漏/叠加。

测试(hash-scroll.test.ts)新增 3 例:resize 后再校正、用户 wheel 后停止、重入清理旧 RO。
2026-07-16 16:05:11 +08:00

224 lines
7.5 KiB
TypeScript
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.

/**
* hash-scroll 测试:钉住锚点跳转的解码与手动偏移滚动行为。
* 黑盒驱动:只通过 window.__scrollToHash 入口。
*
* 现在用 window.scrollTo 手动扣除 sticky header 高度(不再依赖 scrollIntoView +
* scroll-margin-top因为浏览器原生 fragment-scroll 对后者的应用时序不稳定)。
*
* 布局稳定期ResizeObserver测试钉住异步布局位移mermaid 懒加载注入 SVG、
* 图片/字体加载撑高内容)后落点会重新校正,且用户主动滚动后停止校正。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import './index';
// --- ResizeObserver mockhappy-dom 不实现,手动提供回调钩子 ---
let resizeCallback:
| ((entries: { target: Element; contentRect: { width: number; height: number } }[]) => void)
| null = null;
const roDisconnect = vi.fn();
vi.stubGlobal(
'ResizeObserver',
class {
constructor(cb: typeof resizeCallback) {
resizeCallback = cb;
}
observe() {}
disconnect() {
roDisconnect();
}
},
);
describe('scrollToHash', () => {
beforeEach(() => {
document.body.innerHTML = '';
history.replaceState(null, '', '#');
vi.restoreAllMocks();
resizeCallback = null;
roDisconnect.mockClear();
});
afterEach(() => {
document.body.innerHTML = '';
history.replaceState(null, '', '#');
});
it('hash 为 CJK 百分号编码时解码后命中并滚动', () => {
const heading = document.createElement('h2');
heading.id = '三-五-零法则';
document.body.appendChild(heading);
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
history.replaceState(null, '', `#${encodeURIComponent(heading.id)}`);
window.__scrollToHash();
expect(spy).toHaveBeenCalled();
});
it('hash 为纯 ASCII 时直接命中', () => {
const heading = document.createElement('h2');
heading.id = 'getting-started';
document.body.appendChild(heading);
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
history.replaceState(null, '', '#getting-started');
window.__scrollToHash();
expect(spy).toHaveBeenCalled();
});
it('滚动落点扣除 sticky header 高度', () => {
// 模拟 sticky headergetBoundingClientRect 返回 height 80。
const header = document.createElement('header');
header.className = 'sticky';
header.getBoundingClientRect = () =>
({
top: 0,
left: 0,
right: 0,
bottom: 80,
width: 1000,
height: 80,
x: 0,
y: 0,
toJSON() {},
}) as DOMRect;
document.body.appendChild(header);
// 目标标题:放在 scrollY=1000 处getBoundingClientRect.top 反映相对视口位置)。
const heading = document.createElement('h2');
heading.id = 'mid';
heading.getBoundingClientRect = () =>
({
top: 1000,
left: 0,
right: 0,
bottom: 1050,
width: 800,
height: 50,
x: 0,
y: 1000,
toJSON() {},
}) as DOMRect;
document.body.appendChild(heading);
// scrollY 参与 scrollTo 目标计算top = rect.top + scrollY - headerH - offset。
Object.defineProperty(window, 'scrollY', { value: 500, writable: true, configurable: true });
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
history.replaceState(null, '', '#mid');
window.__scrollToHash();
// 第一帧调用smoothtop = 1000 + 500 - 80 - 16 = 1404
expect(spy).toHaveBeenCalled();
const firstCall = spy.mock.calls[0][0] as ScrollToOptions;
expect(firstCall.top).toBe(1000 + 500 - 80 - 16);
expect(firstCall.behavior).toBe('smooth');
});
it('hash 为空时不滚动也不报错', () => {
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
history.replaceState(null, '', '#');
expect(() => window.__scrollToHash()).not.toThrow();
expect(spy).not.toHaveBeenCalled();
});
it('目标元素不存在时不报错', () => {
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
history.replaceState(null, '', '#no-such-heading');
expect(() => window.__scrollToHash()).not.toThrow();
expect(spy).not.toHaveBeenCalled();
});
// ---------- 布局稳定期ResizeObserver ----------
// 复现 bug 根因mermaid 懒加载等异步位移源发生在两帧校正之后,撑高内容把标题
// 推离落点。稳定期监听内容容器 resize位移后重新校正一次。
/** 构造一个带 .post-content 容器和带 id 标题的 DOM用于稳定期测试。 */
function setupPostWithHeading(headingId: string): { root: HTMLElement; heading: HTMLElement } {
const root = document.createElement('div');
root.className = 'post-content';
const heading = document.createElement('h2');
heading.id = headingId;
heading.getBoundingClientRect = () =>
({
top: 1000,
left: 0,
right: 0,
bottom: 1050,
width: 800,
height: 50,
x: 0,
y: 0,
toJSON() {},
}) as DOMRect;
root.appendChild(heading);
document.body.appendChild(root);
Object.defineProperty(window, 'scrollY', { value: 500, writable: true, configurable: true });
return { root, heading };
}
it('内容容器 resize 后再次校正落点(异步布局位移修正)', async () => {
vi.useFakeTimers();
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
setupPostWithHeading('mermaid-tu-biao');
history.replaceState(null, '', '#mermaid-tu-biao');
window.__scrollToHash();
// 推进两帧校正的 rAFfake rAF 周期 ~16ms不触发 2s 超时。
await vi.advanceTimersByTimeAsync(40);
spy.mockClear();
expect(resizeCallback).not.toBeNull();
// 模拟 mermaid 注入 SVG 撑高内容:触发一次 resize。
resizeCallback!([
{
target: document.querySelector('.post-content')!,
contentRect: { width: 800, height: 2000 },
},
]);
// rAF 合并的校正在下一帧执行。
await vi.advanceTimersByTimeAsync(20);
expect(spy).toHaveBeenCalled();
// 推进超时,让稳定器在本测试内自清理,避免泄漏到后续测试。
await vi.advanceTimersByTimeAsync(2000);
vi.useRealTimers();
});
it('用户主动滚动wheel后停止校正避免与用户交互打架', () => {
vi.useFakeTimers();
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
setupPostWithHeading('h2');
history.replaceState(null, '', '#h2');
window.__scrollToHash();
expect(roDisconnect).not.toHaveBeenCalled();
// 用户主动滚动:派发 wheel应触发 disposeRO 断开、监听移除、超时清除)。
window.dispatchEvent(new WheelEvent('wheel'));
expect(roDisconnect).toHaveBeenCalled();
// dispose 后 resize 不再触发新的稳定器RO 已断开,回调不会再被调用。
expect(resizeCallback).not.toBeNull();
vi.useRealTimers();
});
it('重入安全:再次 scrollToHash 时旧稳定器被清理(不泄漏/不叠加)', () => {
vi.useFakeTimers();
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
setupPostWithHeading('h3');
history.replaceState(null, '', '#h3');
window.__scrollToHash();
expect(roDisconnect).not.toHaveBeenCalled();
// 第二次调用(模拟主题切换触发 use_effect 重跑)。
window.__scrollToHash();
expect(roDisconnect).toHaveBeenCalled();
vi.useRealTimers();
});
});