diff --git a/libs/yggdrasil-core/src/hash-scroll.test.ts b/libs/yggdrasil-core/src/hash-scroll.test.ts index 872aa37..a07ee3e 100644 --- a/libs/yggdrasil-core/src/hash-scroll.test.ts +++ b/libs/yggdrasil-core/src/hash-scroll.test.ts @@ -4,15 +4,38 @@ * * 现在用 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 mock:happy-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 = ''; @@ -108,4 +131,93 @@ describe('scrollToHash', () => { 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(); + // 推进两帧校正的 rAF(fake 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,应触发 dispose(RO 断开、监听移除、超时清除)。 + 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(); + }); }); diff --git a/libs/yggdrasil-core/src/hash-scroll.ts b/libs/yggdrasil-core/src/hash-scroll.ts index 5a98856..99a1565 100644 --- a/libs/yggdrasil-core/src/hash-scroll.ts +++ b/libs/yggdrasil-core/src/hash-scroll.ts @@ -10,6 +10,11 @@ * 解法:用 requestAnimationFrame 延后两帧再做最终校正,确保我们的落点在原生 * scroll 之后生效(原生 scroll 通常在下一帧完成)。 * + * 异步布局位移问题:两帧校正之后仍可能有异步内容撑高布局——mermaid 懒加载 + * 注入 SVG、图片/字体加载等发生在两帧之后,把目标标题推离已校准的落点,导致 + * 直接访问带 hash 的 URL 时标题停在屏幕中间。解法:两帧校正后开启一个 ResizeObserver + * 布局稳定期(见 stabilizeScrollOnResize),位移后重新校正,直到超时或用户主动滚动。 + * * CJK 编码要点:location.hash 返回百分号编码形式(%E4%B8%89-...),而标题 id * 属性是原始字符(id="三-五-零法则")。必须先 decodeURIComponent 还原,getElementById * 才能匹配。双 fallback(先解码、后原始)兼容两种情况。 @@ -17,6 +22,75 @@ import { scrollToHeading } from './scroll-to-heading'; +/** 布局稳定期时长:覆盖 mermaid bundle 加载+渲染、图片/字体加载等异步位移源。 */ +const STABILIZE_WINDOW_MS = 2000; + +/** 用户主动滚动的输入事件:收到任一即认为用户接管,停止自动校正。 */ +const USER_INPUT_EVENTS: Array = ['wheel', 'touchmove', 'keydown']; + +/** 当前活跃的稳定器;重入时先 dispose 上一次,避免泄漏/叠加。 */ +let activeStabilizer: (() => void) | null = null; + +/** + * 布局稳定期:在 STABILIZE_WINDOW_MS 内监听内容容器尺寸变化,位移后用 rAF 合并 + * 并重新校正落点。用户主动滚动或超时后自动停止。 + * + * 为什么观察容器而非标题本身:标题自身尺寸不变(ResizeObserver 不触发),位移来自 + * 它上方的 mermaid/图片撑高。观察 .post-content 容器(标题最近的正文祖先)能覆盖 + * 所有可能撑高的内容,且与具体位移源(mermaid/图片/字体)解耦。 + */ +function stabilizeScrollOnResize(el: Element): void { + // 重入安全:dispose 上一次活跃的稳定器。 + activeStabilizer?.(); + activeStabilizer = null; + + if (typeof ResizeObserver === 'undefined') return; + + const target = el.closest('.post-content') ?? document.body; + let rafId = 0; + let disposed = false; + + const dispose = () => { + if (disposed) return; + disposed = true; + ro.disconnect(); + window.clearTimeout(timerId); + for (const evt of USER_INPUT_EVENTS) { + window.removeEventListener(evt, onUserInput, { capture: true } as EventListenerOptions); + } + if (rafId) window.cancelAnimationFrame(rafId); + if (activeStabilizer === dispose) activeStabilizer = null; + }; + + // rAF 合并:同帧多次 resize 只校正一次,避免抖动。 + const scheduleCorrect = () => { + if (disposed) return; + if (rafId) return; + rafId = window.requestAnimationFrame(() => { + rafId = 0; + if (!disposed) scrollToHeading(el, false); + }); + }; + + // 用户主动滚动即停止校正:不与用户交互打架。 + // 用输入事件而非 scroll 事件——window.scrollTo 本身会触发 scroll,无法区分。 + const onUserInput = () => dispose(); + for (const evt of USER_INPUT_EVENTS) { + window.addEventListener(evt, onUserInput, { + capture: true, + passive: true, + } as AddEventListenerOptions); + } + + const ro = new ResizeObserver(scheduleCorrect); + ro.observe(target); + + // 超时兜底:无论如何 STABILIZE_WINDOW_MS 后停止,防止长期占用。 + const timerId = window.setTimeout(dispose, STABILIZE_WINDOW_MS); + + activeStabilizer = dispose; +} + export function scrollToHash(): void { const hash = window.location.hash.slice(1); // 去掉前导 # if (!hash) return; @@ -34,4 +108,8 @@ export function scrollToHash(): void { requestAnimationFrame(() => { requestAnimationFrame(() => scrollToHeading(el, false)); }); + + // 布局稳定期:覆盖两帧之后的异步位移(mermaid 懒加载、图片/字体加载等), + // 位移后重新校正。用户主动滚动或超时后自动停止。 + stabilizeScrollOnResize(el); }