diff --git a/src/components/sidebarTableOfContents/index.tsx b/src/components/sidebarTableOfContents/index.tsx index 319066c07c94d..d1d45269921ec 100644 --- a/src/components/sidebarTableOfContents/index.tsx +++ b/src/components/sidebarTableOfContents/index.tsx @@ -71,6 +71,36 @@ function buildTocTree(toc: TocItem[]): TocItem[] { return items; } +function getTocItems(main: HTMLElement) { + return Array.from(main.querySelectorAll('h2, h3')) + .map(el => { + const title = el.textContent?.trim(); + if (!el.id || !title) { + return null; + } + // This is a relatively new API, that checks if the element is visible in the document + // With this, we filter out e.g. sections hidden via CSS + if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) { + return null; + } + return { + depth: el.tagName === 'H2' ? 2 : 3, + url: `#${el.id}`, + title, + element: el, + isActive: false, + }; + }) + .filter(isNotNil); +} + +function getMainElement() { + if (typeof document === 'undefined') { + return null; + } + return document.getElementById('main'); +} + // The full, rendered page is required in order to generate the table of // contents since headings can come from child components, included MDX files, // etc. Even though this should hypothetically be doable on the server, methods @@ -83,33 +113,51 @@ export function SidebarTableOfContents() { // gather the toc items on mount useEffect(() => { - if (typeof document === 'undefined') { + const main = getMainElement(); + if (!main) { return; } - const main = document.getElementById('main'); + + setTocItems(getTocItems(main)); + }, []); + + // ensure toc items are kept up-to-date if the DOM changes + useEffect(() => { + const main = getMainElement(); if (!main) { - throw new Error('#main element not found'); + return () => {}; } - const tocItems_ = Array.from(main.querySelectorAll('h2, h3')) - .map(el => { - const title = el.textContent?.trim() ?? ''; - if (!el.id) { - return null; - } - return { - depth: el.tagName === 'H2' ? 2 : 3, - url: `#${el.id}`, - title, - element: el, - isActive: false, - }; - }) - .filter(isNotNil); - setTocItems(tocItems_); - }, []); + const observer = new MutationObserver(() => { + const newTocItems = getTocItems(main); + + // Avoid flashing sidebar elements if nothing changes + if ( + newTocItems.length === tocItems.length && + newTocItems.every((item, index) => item.url === tocItems[index].url) + ) { + return; + } + setTocItems(newTocItems); + }); + + // Start observing the target node for any changes in its subtree + // We only care about: + // * Children being added/removed (childList) + // Any id, class, or style attribute being changed (this approximates CSS changes) + observer.observe(main, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'id', 'style'], + }); + + return () => observer.disconnect(); + }, [tocItems]); + + // Mark the active item based on the scroll position useEffect(() => { - if (tocItems.length === 0) { + if (!tocItems.length) { return () => {}; } // account for the header height