Skip to content
49 changes: 39 additions & 10 deletions src/components/tableOfContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('');

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);
};
Comment thread
sentry[bot] marked this conversation as resolved.
}, [currentHash, treeItems]);
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

return (
<ul>
Expand Down
Loading