From a2fb627ae1e5124df506850cd5561191a25a0ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Sat, 25 Oct 2025 13:04:34 +0200 Subject: [PATCH] Fix scroll jump and scroll reset (#3756) --- .../src/components/SitePage/SitePage.tsx | 5 +- .../src/components/hooks/useScrollPage.ts | 16 ++-- .../components/primitives/ScrollContainer.tsx | 82 ++++++++++++------- 3 files changed, 61 insertions(+), 42 deletions(-) diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index 4f0643cee..b519543b8 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -7,7 +7,6 @@ import { } from '@gitbook/api'; import type { Metadata, Viewport } from 'next'; import { notFound, redirect } from 'next/navigation'; -import React from 'react'; import { PageAside } from '@/components/PageAside'; import { PageBody, PageCover } from '@/components/PageBody'; @@ -75,9 +74,7 @@ export async function SitePage(props: SitePageProps) { insightsDisplayContext={SiteInsightsDisplayContext.Site} /> - - - + ); diff --git a/packages/gitbook/src/components/hooks/useScrollPage.ts b/packages/gitbook/src/components/hooks/useScrollPage.ts index 100174f62..fc000b6fb 100644 --- a/packages/gitbook/src/components/hooks/useScrollPage.ts +++ b/packages/gitbook/src/components/hooks/useScrollPage.ts @@ -7,9 +7,8 @@ import { useHash } from './useHash'; import { usePrevious } from './usePrevious'; /** - * Scroll the page to an anchor point or - * to the top of the page when navigating between pages (pathname) - * or sections of a page (hash). + * Scroll the page to the hash or reset scroll to the top. + * Only triggered while navigating in the app, not for initial load. */ export function useScrollPage() { const hash = useHash(); @@ -17,6 +16,10 @@ export function useScrollPage() { const pathname = usePathname(); const previousPathname = usePrevious(pathname); React.useLayoutEffect(() => { + if (!previousHash && !previousPathname) { + return; + } + // If there is no change in pathname or hash, do nothing if (previousHash === hash && previousPathname === pathname) { return; @@ -31,13 +34,10 @@ export function useScrollPage() { block: 'start', behavior: 'smooth', }); + return; } - return; } - // If there was a hash but not anymore, scroll to top - if (previousHash && !hash) { - window.scrollTo(0, 0); - } + window.scrollTo(0, 0); }, [hash, previousHash, pathname, previousPathname]); } diff --git a/packages/gitbook/src/components/primitives/ScrollContainer.tsx b/packages/gitbook/src/components/primitives/ScrollContainer.tsx index 15bf1c30b..369881719 100644 --- a/packages/gitbook/src/components/primitives/ScrollContainer.tsx +++ b/packages/gitbook/src/components/primitives/ScrollContainer.tsx @@ -3,6 +3,7 @@ import { tString, useLanguage } from '@/intl/client'; import { tcls } from '@/lib/tailwind'; import * as React from 'react'; +import { useScrollListener } from '../hooks/useScrollListener'; import { Button } from './Button'; /** @@ -46,56 +47,53 @@ export function ScrollContainer(props: ScrollContainerProps) { const language = useLanguage(); + useScrollListener(() => { + const container = containerRef.current; + if (!container) { + return; + } + + setScrollPosition( + orientation === 'horizontal' ? container.scrollLeft : container.scrollTop + ); + }, containerRef); + React.useEffect(() => { const container = containerRef.current; if (!container) { return; } - // Update scroll position on scroll using requestAnimationFrame - const scrollListener: EventListener = () => { - requestAnimationFrame(() => { - setScrollPosition( - orientation === 'horizontal' ? container.scrollLeft : container.scrollTop - ); - }); - }; - container.addEventListener('scroll', scrollListener); - // Update max scroll position using resize observer - const resizeObserver = new ResizeObserver((entries) => { - const containerEntry = entries.find((i) => i.target === containerRef.current); - if (containerEntry) { + const ro = new ResizeObserver((entries) => { + const [entry] = entries; + if (entry) { setScrollSize( orientation === 'horizontal' - ? containerEntry.target.scrollWidth - containerEntry.target.clientWidth - 1 - : containerEntry.target.scrollHeight - - containerEntry.target.clientHeight - - 1 + ? entry.target.scrollWidth - entry.target.clientWidth - 1 + : entry.target.scrollHeight - entry.target.clientHeight - 1 ); } }); - resizeObserver.observe(container); - return () => { - container.removeEventListener('scroll', scrollListener); - resizeObserver.disconnect(); - }; + ro.observe(container); + + return () => ro.disconnect(); }, [orientation]); - // Scroll to the active item React.useEffect(() => { const container = containerRef.current; - if (!container || !activeId) { + if (!container) { return; } - const activeItem = container.querySelector(`#${CSS.escape(activeId)}`); - if (activeItem) { - activeItem.scrollIntoView({ - inline: 'center', - block: 'center', - }); + if (!activeId) { + return; } + const activeItem = document.getElementById(activeId); + if (!activeItem || !container.contains(activeItem)) { + return; + } + scrollToElementInContainer(activeItem, container); }, [activeId]); const scrollFurther = () => { @@ -103,6 +101,7 @@ export function ScrollContainer(props: ScrollContainerProps) { if (!container) { return; } + container.scrollTo({ top: orientation === 'vertical' ? scrollPosition + container.clientHeight : undefined, left: orientation === 'horizontal' ? scrollPosition + container.clientWidth : undefined, @@ -115,6 +114,7 @@ export function ScrollContainer(props: ScrollContainerProps) { if (!container) { return; } + container.scrollTo({ top: orientation === 'vertical' ? scrollPosition - container.clientHeight : undefined, left: orientation === 'horizontal' ? scrollPosition - container.clientWidth : undefined, @@ -194,3 +194,25 @@ export function ScrollContainer(props: ScrollContainerProps) { ); } + +/** + * Scroll to an element in a container. + */ +function scrollToElementInContainer(element: HTMLElement, container: HTMLElement) { + const containerRect = container.getBoundingClientRect(); + const rect = element.getBoundingClientRect(); + + return container.scrollTo({ + top: + container.scrollTop + + (rect.top - containerRect.top) - + container.clientHeight / 2 + + rect.height / 2, + left: + container.scrollLeft + + (rect.left - containerRect.left) - + container.clientWidth / 2 + + rect.width / 2, + behavior: 'smooth', + }); +}