From 672513678152fb4631d425db44faeb4177b90129 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 9 Feb 2026 13:50:43 +0000 Subject: [PATCH] fix: restore anchor scrolling on initial page load Docusaurus skips hash-based scrolling on initial page load because previousLocation is null. It relies on the browser to handle it natively, but React hydration resets the scroll position before the browser finishes its native anchor scroll. Add a clientModule that explicitly scrolls to the hash target after hydration settles, filling the gap left by the core router. Closes CLO-805 --- docusaurus.config.ts | 4 +++ src/clientModules/scrollToAnchor.ts | 51 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/clientModules/scrollToAnchor.ts diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 251ed17a..8e73b539 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -59,6 +59,10 @@ const config: Config = { locales: ['en'], }, + clientModules: [ + require.resolve('./src/clientModules/scrollToAnchor.ts'), + ], + presets: [ [ 'classic', diff --git a/src/clientModules/scrollToAnchor.ts b/src/clientModules/scrollToAnchor.ts new file mode 100644 index 00000000..296ce926 --- /dev/null +++ b/src/clientModules/scrollToAnchor.ts @@ -0,0 +1,51 @@ +import type {ClientModule} from '@docusaurus/types'; + +/** + * Docusaurus's built-in scroll handler skips hash-based scrolling on initial + * page load (when previousLocation is null). It assumes the browser handles it + * natively, but React hydration can reset the scroll position before the + * browser finishes its native anchor scroll. This client module fills that gap + * by explicitly scrolling to the hash target after hydration settles. + * + * @see https://linear.app/roocode/issue/CLO-805 + */ +const scrollToAnchorModule: ClientModule = { + onRouteDidUpdate({location, previousLocation}) { + // Docusaurus core already handles hash scrolling for subsequent + // navigations. This module only covers the initial page load case. + if (previousLocation != null) { + return; + } + + const {hash} = location; + if (!hash) { + return; + } + + const id = decodeURIComponent(hash.substring(1)); + if (!id) { + return; + } + + // Wait for the next animation frame so that React hydration has settled + // and the DOM reflects the final rendered state. + requestAnimationFrame(() => { + const element = document.getElementById(id); + if (element) { + // scrollIntoView respects the scroll-margin-top set by + // Docusaurus's anchorTargetStickyNavbar class on headings. + element.scrollIntoView(); + return; + } + + // If the element wasn't found on the first frame (e.g. lazy-loaded + // content), retry once after a short delay. + setTimeout(() => { + const retryElement = document.getElementById(id); + retryElement?.scrollIntoView(); + }, 150); + }); + }, +}; + +export default scrollToAnchorModule;