From 11102022fd44dada7e652599fbe8d94930a91171 Mon Sep 17 00:00:00 2001 From: apple <245524539+apples-kksk@users.noreply.github.com> Date: Sun, 10 May 2026 00:14:36 +0800 Subject: [PATCH 1/2] fix: keep anchor scroll aligned after layout changes --- src/core/event/index.js | 81 +++++++++++++++++++++++++++++++--- test/e2e/anchor-scroll.test.js | 74 +++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 test/e2e/anchor-scroll.test.js diff --git a/src/core/event/index.js b/src/core/event/index.js index e9c446cee0..a709a12b18 100644 --- a/src/core/event/index.js +++ b/src/core/event/index.js @@ -1,4 +1,5 @@ import { isMobile, mobileBreakpoint } from '../util/env.js'; +import { noop } from '../util/core.js'; import * as dom from '../util/dom.js'; import { stripUrlExceptId } from '../router/util.js'; @@ -12,6 +13,7 @@ export function Events(Base) { return class Events extends Base { #intersectionObserver = new IntersectionObserver(() => {}); #isScrolling = false; + #cancelAnchorScroll = noop; #title = dom.$.title; // Initialization @@ -374,11 +376,7 @@ export function Events(Base) { ); if (headingElm) { - this.#watchNextScroll(); - headingElm.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); + this.#scrollToHeading(headingElm); } } // User click/tap @@ -606,6 +604,79 @@ export function Events(Base) { } } + /** + * Scroll an anchor target into view and keep it aligned while late-loading + * content above the target changes the page height. + * + * @param {Element} headingElm Heading element to scroll to + * @void + */ + #scrollToHeading(headingElm) { + this.#cancelAnchorScroll(); + + const contentElm = dom.find('.markdown-section'); + const userEvents = ['keydown', 'mousedown', 'touchstart', 'wheel']; + /** @type {{ max?: ReturnType, settle?: ReturnType }} */ + const timers = {}; + let cancel = noop; + + const removeUserListeners = () => { + userEvents.forEach(eventName => { + window.removeEventListener(eventName, cancel); + }); + }; + + /** @param {ScrollBehavior} [behavior] */ + const scrollToHeading = (behavior = 'smooth') => { + if (!document.contains(headingElm)) { + cancel(); + return; + } + + this.#watchNextScroll(); + headingElm.scrollIntoView({ + behavior, + block: 'start', + }); + }; + + const resync = () => { + scrollToHeading('instant'); + clearTimeout(timers.settle); + timers.settle = setTimeout(cancel, 500); + }; + + scrollToHeading(); + + if (!contentElm || !('ResizeObserver' in window)) { + return; + } + + const resizeObserver = new ResizeObserver(resync); + + cancel = () => { + resizeObserver.disconnect(); + clearTimeout(timers.settle); + clearTimeout(timers.max); + removeUserListeners(); + window.removeEventListener('load', resync); + this.#cancelAnchorScroll = noop; + }; + + resizeObserver.observe(contentElm); + userEvents.forEach(eventName => { + window.addEventListener(eventName, cancel, { + once: true, + passive: true, + }); + }); + window.addEventListener('load', resync, { once: true }); + timers.max = setTimeout(cancel, 3000); + requestAnimationFrame(() => requestAnimationFrame(resync)); + + this.#cancelAnchorScroll = cancel; + } + /** * Monitor next scroll start/end and set #isScrolling to true/false * accordingly. Listeners are removed after the start/end events are fired. diff --git a/test/e2e/anchor-scroll.test.js b/test/e2e/anchor-scroll.test.js new file mode 100644 index 0000000000..f477fdcb50 --- /dev/null +++ b/test/e2e/anchor-scroll.test.js @@ -0,0 +1,74 @@ +import docsifyInit from '../helpers/docsify-init.js'; +import { test, expect } from './fixtures/docsify-init-fixture.js'; + +test.describe('Anchor scrolling', () => { + test('keeps direct anchor targets aligned after images above them load', async ({ + page, + }) => { + await page.route('**/slow-anchor-image.svg', async route => { + await new Promise(resolve => setTimeout(resolve, 250)); + await route.fulfill({ + contentType: 'image/svg+xml', + body: ` + + + + `, + }); + }); + + await docsifyInit({ + testURL: '/docsify-init.html#/?id=target-section', + markdown: { + homepage: ` + # Anchor Scroll + + ![Slow image](/slow-anchor-image.svg) + + ## Middle Section + + This section should not stay at the top after the image loads. + + ## Target Section + + This is the linked section. + + Trailing content keeps the target scrollable. + `, + }, + routes: { + '/docsify-init.html': ` + + + + + + +
+ + + `, + }, + style: ` + .markdown-section img { + display: block; + width: 100%; + height: auto; + } + + .markdown-section { + padding-bottom: 1200px; + } + `, + styleURLs: ['/dist/themes/core.css'], + }); + + await expect + .poll(async () => { + return page.locator('#target-section').evaluate(el => { + return el.getBoundingClientRect().top; + }); + }) + .toBeLessThan(80); + }); +}); From cf22daf989f3968be8030f8be85726f93a59ef51 Mon Sep 17 00:00:00 2001 From: apple <245524539+apples-kksk@users.noreply.github.com> Date: Sun, 10 May 2026 13:03:11 +0800 Subject: [PATCH 2/2] Address anchor scroll review feedback --- src/core/event/index.js | 28 ++++++++++++++++++++++++++-- test/e2e/anchor-scroll.test.js | 24 +++++++++++++++++------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/core/event/index.js b/src/core/event/index.js index a709a12b18..f281de345a 100644 --- a/src/core/event/index.js +++ b/src/core/event/index.js @@ -618,6 +618,9 @@ export function Events(Base) { const userEvents = ['keydown', 'mousedown', 'touchstart', 'wheel']; /** @type {{ max?: ReturnType, settle?: ReturnType }} */ const timers = {}; + /** @type {number[]} */ + const animationFrames = []; + let cancelled = false; let cancel = noop; const removeUserListeners = () => { @@ -628,12 +631,19 @@ export function Events(Base) { /** @param {ScrollBehavior} [behavior] */ const scrollToHeading = (behavior = 'smooth') => { + if (cancelled) { + return; + } + if (!document.contains(headingElm)) { cancel(); return; } - this.#watchNextScroll(); + if (behavior === 'smooth') { + this.#watchNextScroll(); + } + headingElm.scrollIntoView({ behavior, block: 'start', @@ -641,6 +651,10 @@ export function Events(Base) { }; const resync = () => { + if (cancelled) { + return; + } + scrollToHeading('instant'); clearTimeout(timers.settle); timers.settle = setTimeout(cancel, 500); @@ -655,7 +669,13 @@ export function Events(Base) { const resizeObserver = new ResizeObserver(resync); cancel = () => { + if (cancelled) { + return; + } + + cancelled = true; resizeObserver.disconnect(); + animationFrames.forEach(cancelAnimationFrame); clearTimeout(timers.settle); clearTimeout(timers.max); removeUserListeners(); @@ -672,7 +692,11 @@ export function Events(Base) { }); window.addEventListener('load', resync, { once: true }); timers.max = setTimeout(cancel, 3000); - requestAnimationFrame(() => requestAnimationFrame(resync)); + animationFrames.push( + requestAnimationFrame(() => { + animationFrames.push(requestAnimationFrame(resync)); + }), + ); this.#cancelAnchorScroll = cancel; } diff --git a/test/e2e/anchor-scroll.test.js b/test/e2e/anchor-scroll.test.js index f477fdcb50..5d15eae8bf 100644 --- a/test/e2e/anchor-scroll.test.js +++ b/test/e2e/anchor-scroll.test.js @@ -63,12 +63,22 @@ test.describe('Anchor scrolling', () => { styleURLs: ['/dist/themes/core.css'], }); - await expect - .poll(async () => { - return page.locator('#target-section').evaluate(el => { - return el.getBoundingClientRect().top; - }); - }) - .toBeLessThan(80); + await page.locator('img[alt="Slow image"]').waitFor(); + await page.waitForFunction(() => { + const image = document.querySelector('img[alt="Slow image"]'); + const target = document.querySelector('#target-section'); + return ( + image instanceof HTMLImageElement && + image.complete && + image.naturalHeight > 0 && + target instanceof HTMLElement && + target.getBoundingClientRect().top < 80 + ); + }); + + const targetTop = await page.locator('#target-section').evaluate(el => { + return el.getBoundingClientRect().top; + }); + expect(targetTop).toBeLessThan(80); }); });