diff --git a/src/components/tableOfContents.tsx b/src/components/tableOfContents.tsx index d0d6c5626e6e1..4a697516d98ce 100644 --- a/src/components/tableOfContents.tsx +++ b/src/components/tableOfContents.tsx @@ -71,24 +71,53 @@ export function TableOfContents({ignoreIds = []}: Props) { setTreeItems(_tocItems); }, [ignoreIds]); + // Track current hash to trigger scroll when it changes (e.g., browser back/forward) + const [currentHash, setCurrentHash] = useState(''); + // Track which hash we've scrolled to for this navigation, to avoid re-scrolling + // when treeItems rebuild due to parent re-renders + const scrolledHashRef = useRef(''); + + useEffect(() => { + // Initialize hash and listen for changes + setCurrentHash(window.location.hash); + const handleHashChange = () => { + setCurrentHash(window.location.hash); + // Reset scroll tracking on navigation + scrolledHashRef.current = ''; + }; + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + // Re-scroll to hash anchor after TOC renders to compensate for layout shift. // The TOC starts empty and populates client-side, which pushes content down // and causes the browser's initial anchor scroll to land on the wrong section. - const hasScrolledToHash = useRef(false); + // This effect runs when: + // - treeItems populate initially (layout shift correction) + // - Hash changes (navigation, back/forward) + // BUT NOT when treeItems rebuild due to parent re-renders (tracked via ref). + // + // Note: This causes a redundant scroll when clicking TOC links (browser scrolls, + // then our effect scrolls again), but the performance impact is negligible and + // ensures correct behavior for initial page loads and browser back/forward navigation. useEffect(() => { - if (hasScrolledToHash.current || treeItems.length === 0) { - return; + if (treeItems.length === 0 || !currentHash) { + return undefined; } - const hash = window.location.hash; - if (!hash) { - return; + // Skip if we've already scrolled to this hash during this navigation + if (scrolledHashRef.current === currentHash) { + return undefined; } - hasScrolledToHash.current = true; - requestAnimationFrame(() => { - const id = decodeURIComponent(hash.slice(1)); + const rafId = requestAnimationFrame(() => { + // Set the ref inside RAF to ensure we only mark as scrolled when it actually happens + scrolledHashRef.current = currentHash; + const id = decodeURIComponent(currentHash.slice(1)); document.getElementById(id)?.scrollIntoView(); }); - }, [treeItems]); + return () => { + cancelAnimationFrame(rafId); + }; + }, [currentHash, treeItems]); return (