From 105c1d337a8bb3a9ef7b0b6d4aa5c3d2d86ee7bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:49:41 +0000 Subject: [PATCH] Simplify anchor scroll retry to a single bounded loop (RND-11844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse scrollToHash to one requestAnimationFrame loop: scroll to the target whenever it's present, retrying ~1s for it to commit and holding ~0.3s afterward so we scroll after Next's late scroll-to-top. Drops the separate wheel/touchmove/keydown abort machinery — the hold window is short enough that a user scrolling within it is a rare, brief edge, not worth the ceremony. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TpGe6QkAF4Y1HDGUD4vbke --- .../src/components/hooks/useScrollPage.ts | 75 ++++--------------- 1 file changed, 16 insertions(+), 59 deletions(-) diff --git a/packages/gitbook/src/components/hooks/useScrollPage.ts b/packages/gitbook/src/components/hooks/useScrollPage.ts index 38c80fbb2..fc4767c74 100644 --- a/packages/gitbook/src/components/hooks/useScrollPage.ts +++ b/packages/gitbook/src/components/hooks/useScrollPage.ts @@ -80,72 +80,29 @@ export function useScrollToHash() { let cancelScrollToHash: (() => void) | null = null; /** - * Scroll to the element matching a hash, robustly, during a client-side navigation. + * Scroll to the element matching a hash during a client-side navigation. * - * Two things fight a naive one-shot scroll on a cross-page anchor link (`/page#heading`): - * 1. The hash is known on link click, before the destination content commits, so the target - * element doesn't exist yet on the first attempt. - * 2. Next's scroll restoration fires a `window.scrollTo(0, 0)` *after* the destination commits, - * overriding a single `scrollIntoView` and leaving the page at the top. - * So we retry until the element exists, then keep re-asserting the scroll for a short window to win - * against that late reset — bailing immediately if the user scrolls, so we never hijack their intent. + * On a cross-page anchor link the target commits a few frames after the hash is known, and Next's + * scroll restoration then fires a `scrollTo(0, 0)` that overrides a one-shot scroll. So retry until + * the element exists, then re-assert briefly so GitBook scrolls last. `instant` (not `smooth`) can't + * be interrupted mid-animation, and re-scrolling to a reached position is a no-op. */ function scrollToHash(hash: string) { cancelScrollToHash?.(); - // ~1s to wait for the element to commit; ~0.3s of re-asserting once it has. - const maxFramesToFind = 60; - const framesToHoldAfterFound = 20; - let frame = 0; - let framesSinceFound = 0; - let rafId: number | null = null; - let aborted = false; - - const onUserScroll = () => { - aborted = true; - }; - - const cleanup = () => { - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } - window.removeEventListener('wheel', onUserScroll); - window.removeEventListener('touchmove', onUserScroll); - window.removeEventListener('keydown', onUserScroll); - cancelScrollToHash = null; - }; - cancelScrollToHash = cleanup; - - window.addEventListener('wheel', onUserScroll, { passive: true }); - window.addEventListener('touchmove', onUserScroll, { passive: true }); - window.addEventListener('keydown', onUserScroll); - - const tick = () => { - if (aborted) { - cleanup(); - return; - } - + let framesAfterFound = 0; + let rafId = requestAnimationFrame(function tick() { const element = document.getElementById(hash); - if (element) { - // `instant` (not `smooth`): a smooth animation is easily interrupted by the competing - // scroll reset, and re-asserting an instant scroll to a position already reached is a - // no-op, so holding costs nothing. - element.scrollIntoView({ block: 'start', behavior: 'instant' }); - if (framesSinceFound++ >= framesToHoldAfterFound) { - cleanup(); - return; - } - } else if (frame >= maxFramesToFind) { - cleanup(); - return; + element?.scrollIntoView({ block: 'start', behavior: 'instant' }); + + // Retry ~1s for the element to commit; once found, hold ~0.3s to outlast the reset. + if (frame++ < 60 && (!element || framesAfterFound++ < 20)) { + rafId = requestAnimationFrame(tick); + } else { + cancelScrollToHash = null; } + }); - frame++; - rafId = requestAnimationFrame(tick); - }; - - tick(); + cancelScrollToHash = () => cancelAnimationFrame(rafId); }