From 8824fef0c1f9d4da1155df2a9601f6bf50e954dc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 16:59:20 +0000 Subject: [PATCH 1/7] fix(toc): Reset anchor scroll flag on hash change The TableOfContents component uses a ref to track whether it has scrolled to a hash anchor, preventing duplicate scrolls after the TOC renders. However, this flag was never reset, causing issues with browser back/forward navigation. When clicking back to restore a URL with a hash anchor, the browser correctly updated the URL but the page didn't scroll to the anchor section because the component thought it had already handled that scroll. Add a hashchange event listener that resets the scroll flag when the hash changes, allowing the component to properly handle browser back/forward navigation and other hash changes. Co-Authored-By: Claude Co-authored-by: Paul Jaffre --- src/components/tableOfContents.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/tableOfContents.tsx b/src/components/tableOfContents.tsx index d0d6c5626e6e11..408eb73a593487 100644 --- a/src/components/tableOfContents.tsx +++ b/src/components/tableOfContents.tsx @@ -75,6 +75,23 @@ export function TableOfContents({ignoreIds = []}: Props) { // 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); + const lastHash = useRef(''); + + // Reset scroll flag when hash changes (e.g., via browser back/forward) + useEffect(() => { + const handleHashChange = () => { + const currentHash = window.location.hash; + if (currentHash !== lastHash.current) { + hasScrolledToHash.current = false; + lastHash.current = currentHash; + } + }; + + // Listen for hash changes + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + useEffect(() => { if (hasScrolledToHash.current || treeItems.length === 0) { return; @@ -84,6 +101,7 @@ export function TableOfContents({ignoreIds = []}: Props) { return; } hasScrolledToHash.current = true; + lastHash.current = hash; requestAnimationFrame(() => { const id = decodeURIComponent(hash.slice(1)); document.getElementById(id)?.scrollIntoView(); From 0223ef4828d2d1c9a54c306fa800c0b0e174ba02 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 17:09:27 +0000 Subject: [PATCH 2/7] ref(toc): Use state instead of refs for hash tracking Refactor the hash scroll logic to use React state instead of refs and event listeners. This is more idiomatic and maintainable: - Replace hasScrolledToHash ref with currentHash state - Remove lastHash ref tracking - Let React's dependency system handle re-execution when hash changes - Simplify the logic by eliminating manual flag management The effect now automatically re-runs when either the hash or treeItems change, making the behavior more predictable and easier to understand. Co-Authored-By: Claude Co-authored-by: Paul Jaffre --- src/components/tableOfContents.tsx | 38 ++++++++++-------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/components/tableOfContents.tsx b/src/components/tableOfContents.tsx index 408eb73a593487..1aee407dce2858 100644 --- a/src/components/tableOfContents.tsx +++ b/src/components/tableOfContents.tsx @@ -1,6 +1,6 @@ 'use client'; -import {useEffect, useRef, useState} from 'react'; +import {useEffect, useState} from 'react'; type TreeItem = { children: TreeItem[]; @@ -71,42 +71,30 @@ export function TableOfContents({ignoreIds = []}: Props) { setTreeItems(_tocItems); }, [ignoreIds]); - // 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); - const lastHash = useRef(''); + // Track current hash to trigger scroll when it changes (e.g., browser back/forward) + const [currentHash, setCurrentHash] = useState(''); - // Reset scroll flag when hash changes (e.g., via browser back/forward) useEffect(() => { - const handleHashChange = () => { - const currentHash = window.location.hash; - if (currentHash !== lastHash.current) { - hasScrolledToHash.current = false; - lastHash.current = currentHash; - } - }; - - // Listen for hash changes + // Initialize hash and listen for changes + setCurrentHash(window.location.hash); + const handleHashChange = () => setCurrentHash(window.location.hash); 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. + // This effect re-runs whenever the hash or treeItems change. useEffect(() => { - if (hasScrolledToHash.current || treeItems.length === 0) { - return; - } - const hash = window.location.hash; - if (!hash) { + if (treeItems.length === 0 || !currentHash) { return; } - hasScrolledToHash.current = true; - lastHash.current = hash; requestAnimationFrame(() => { - const id = decodeURIComponent(hash.slice(1)); + const id = decodeURIComponent(currentHash.slice(1)); document.getElementById(id)?.scrollIntoView(); }); - }, [treeItems]); + }, [currentHash, treeItems]); return (
    From 8584a486726667ce904e6a6308db1a83d9912d61 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 17:14:19 +0000 Subject: [PATCH 3/7] perf(toc): Cancel pending RAF callbacks on re-render Add cleanup function to cancel pending requestAnimationFrame callbacks when the effect re-runs. This prevents multiple scroll operations from queuing up if users rapidly click through TOC links or navigate quickly. The cleanup ensures only the latest scroll request executes, avoiding potential jank from competing scroll operations. Co-Authored-By: Claude Co-authored-by: Paul Jaffre --- src/components/tableOfContents.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/tableOfContents.tsx b/src/components/tableOfContents.tsx index 1aee407dce2858..cc9e39722ccc30 100644 --- a/src/components/tableOfContents.tsx +++ b/src/components/tableOfContents.tsx @@ -90,10 +90,11 @@ export function TableOfContents({ignoreIds = []}: Props) { if (treeItems.length === 0 || !currentHash) { return; } - requestAnimationFrame(() => { + const rafId = requestAnimationFrame(() => { const id = decodeURIComponent(currentHash.slice(1)); document.getElementById(id)?.scrollIntoView(); }); + return () => cancelAnimationFrame(rafId); }, [currentHash, treeItems]); return ( From 8cef5e6e0d81cd16d4062275a9e1c5daf358611d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 17:16:48 +0000 Subject: [PATCH 4/7] fix(toc): Satisfy consistent-return ESLint rule Explicitly return undefined in early exit path to satisfy the consistent-return ESLint rule. Both branches of the useEffect now have explicit return statements. Co-Authored-By: Claude Co-authored-by: Paul Jaffre --- src/components/tableOfContents.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/tableOfContents.tsx b/src/components/tableOfContents.tsx index cc9e39722ccc30..6fad0af30d3479 100644 --- a/src/components/tableOfContents.tsx +++ b/src/components/tableOfContents.tsx @@ -88,13 +88,15 @@ export function TableOfContents({ignoreIds = []}: Props) { // This effect re-runs whenever the hash or treeItems change. useEffect(() => { if (treeItems.length === 0 || !currentHash) { - return; + return undefined; } const rafId = requestAnimationFrame(() => { const id = decodeURIComponent(currentHash.slice(1)); document.getElementById(id)?.scrollIntoView(); }); - return () => cancelAnimationFrame(rafId); + return () => { + cancelAnimationFrame(rafId); + }; }, [currentHash, treeItems]); return ( From 338f3314b214a2a68dbeedc31460c66817084758 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 17:58:20 +0000 Subject: [PATCH 5/7] docs(toc): Document tradeoff for redundant scroll on TOC clicks Add comment explaining why we accept a redundant scroll when clicking TOC links. The Sentry bot flagged this as a potential performance issue, but attempts to optimize it by only scrolling on popstate events broke browser back/forward navigation. The redundant scroll (browser native + our effect) has negligible performance impact and ensures correct behavior for: - Initial page loads with hash (corrects layout shift) - Browser back/forward navigation (corrects scroll position) The alternative (only scrolling on popstate) broke core functionality, making this an acceptable tradeoff. Co-Authored-By: Claude Co-authored-by: Paul Jaffre --- src/components/tableOfContents.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/tableOfContents.tsx b/src/components/tableOfContents.tsx index 6fad0af30d3479..abe6624a4240e3 100644 --- a/src/components/tableOfContents.tsx +++ b/src/components/tableOfContents.tsx @@ -86,6 +86,10 @@ export function TableOfContents({ignoreIds = []}: Props) { // 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. // This effect re-runs whenever the hash or treeItems change. + // + // 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 (treeItems.length === 0 || !currentHash) { return undefined; From 6361092720b6b72c5c069f95b4f1f55051f0c884 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 18:09:13 +0000 Subject: [PATCH 6/7] fix(toc): Prevent unwanted scrolls during TOC rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BugBot identified that parent re-renders can cause unwanted scrolls: - Parent re-renders → new ignoreIds array reference - TOC rebuild effect runs → treeItems state changes - Scroll effect triggers → user yanked back to hash anchor Fix: Track which hash we've scrolled to via ref. When treeItems rebuild but hash hasn't changed, skip the scroll. Reset the ref on hash changes to allow scrolling for navigation/back/forward. This prevents users from being pulled back to anchors while reading, while still supporting: - Initial page load scroll correction - Browser back/forward navigation - TOC link clicks Co-Authored-By: Claude Co-authored-by: Paul Jaffre --- src/components/tableOfContents.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/components/tableOfContents.tsx b/src/components/tableOfContents.tsx index abe6624a4240e3..71c5bee1c2deba 100644 --- a/src/components/tableOfContents.tsx +++ b/src/components/tableOfContents.tsx @@ -1,6 +1,6 @@ 'use client'; -import {useEffect, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; type TreeItem = { children: TreeItem[]; @@ -73,11 +73,18 @@ export function TableOfContents({ignoreIds = []}: Props) { // 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); + const handleHashChange = () => { + setCurrentHash(window.location.hash); + // Reset scroll tracking on navigation + scrolledHashRef.current = ''; + }; window.addEventListener('hashchange', handleHashChange); return () => window.removeEventListener('hashchange', handleHashChange); }, []); @@ -85,7 +92,10 @@ export function TableOfContents({ignoreIds = []}: Props) { // 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. - // This effect re-runs whenever the hash or treeItems change. + // 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 @@ -94,6 +104,11 @@ export function TableOfContents({ignoreIds = []}: Props) { if (treeItems.length === 0 || !currentHash) { return undefined; } + // Skip if we've already scrolled to this hash during this navigation + if (scrolledHashRef.current === currentHash) { + return undefined; + } + scrolledHashRef.current = currentHash; const rafId = requestAnimationFrame(() => { const id = decodeURIComponent(currentHash.slice(1)); document.getElementById(id)?.scrollIntoView(); From 5c7e49f83d8344b1bbc149993f681d1cb8c52ee1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 18:24:41 +0000 Subject: [PATCH 7/7] fix(toc): Move scrolledHashRef assignment inside RAF callback Fix race condition identified by Bugbot: scrolledHashRef was set before the RAF executed, so if a re-render cancelled the RAF, the ref would incorrectly indicate the scroll happened when it didn't. This caused scrolls to be silently skipped. Move the ref assignment inside the RAF callback to ensure it's only marked as scrolled when the scroll actually executes. Fixes: High severity Bugbot issue (race condition) Co-Authored-By: Claude Co-authored-by: Paul Jaffre --- src/components/tableOfContents.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/tableOfContents.tsx b/src/components/tableOfContents.tsx index 71c5bee1c2deba..4a697516d98ceb 100644 --- a/src/components/tableOfContents.tsx +++ b/src/components/tableOfContents.tsx @@ -108,8 +108,9 @@ export function TableOfContents({ignoreIds = []}: Props) { if (scrolledHashRef.current === currentHash) { return undefined; } - scrolledHashRef.current = currentHash; 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(); });