Skip to content

Commit dd5ebac

Browse files
authored
fix: Ensure hidden sections are not show in sidebar TOC (#13201)
1 parent ad782b3 commit dd5ebac

File tree

1 file changed

+69
-21
lines changed
  • src/components/sidebarTableOfContents

1 file changed

+69
-21
lines changed

src/components/sidebarTableOfContents/index.tsx

+69-21
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,36 @@ function buildTocTree(toc: TocItem[]): TocItem[] {
7171
return items;
7272
}
7373

74+
function getTocItems(main: HTMLElement) {
75+
return Array.from(main.querySelectorAll('h2, h3'))
76+
.map(el => {
77+
const title = el.textContent?.trim();
78+
if (!el.id || !title) {
79+
return null;
80+
}
81+
// This is a relatively new API, that checks if the element is visible in the document
82+
// With this, we filter out e.g. sections hidden via CSS
83+
if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) {
84+
return null;
85+
}
86+
return {
87+
depth: el.tagName === 'H2' ? 2 : 3,
88+
url: `#${el.id}`,
89+
title,
90+
element: el,
91+
isActive: false,
92+
};
93+
})
94+
.filter(isNotNil);
95+
}
96+
97+
function getMainElement() {
98+
if (typeof document === 'undefined') {
99+
return null;
100+
}
101+
return document.getElementById('main');
102+
}
103+
74104
// The full, rendered page is required in order to generate the table of
75105
// contents since headings can come from child components, included MDX files,
76106
// etc. Even though this should hypothetically be doable on the server, methods
@@ -83,33 +113,51 @@ export function SidebarTableOfContents() {
83113

84114
// gather the toc items on mount
85115
useEffect(() => {
86-
if (typeof document === 'undefined') {
116+
const main = getMainElement();
117+
if (!main) {
87118
return;
88119
}
89-
const main = document.getElementById('main');
120+
121+
setTocItems(getTocItems(main));
122+
}, []);
123+
124+
// ensure toc items are kept up-to-date if the DOM changes
125+
useEffect(() => {
126+
const main = getMainElement();
90127
if (!main) {
91-
throw new Error('#main element not found');
128+
return () => {};
92129
}
93-
const tocItems_ = Array.from(main.querySelectorAll('h2, h3'))
94-
.map(el => {
95-
const title = el.textContent?.trim() ?? '';
96-
if (!el.id) {
97-
return null;
98-
}
99-
return {
100-
depth: el.tagName === 'H2' ? 2 : 3,
101-
url: `#${el.id}`,
102-
title,
103-
element: el,
104-
isActive: false,
105-
};
106-
})
107-
.filter(isNotNil);
108-
setTocItems(tocItems_);
109-
}, []);
110130

131+
const observer = new MutationObserver(() => {
132+
const newTocItems = getTocItems(main);
133+
134+
// Avoid flashing sidebar elements if nothing changes
135+
if (
136+
newTocItems.length === tocItems.length &&
137+
newTocItems.every((item, index) => item.url === tocItems[index].url)
138+
) {
139+
return;
140+
}
141+
setTocItems(newTocItems);
142+
});
143+
144+
// Start observing the target node for any changes in its subtree
145+
// We only care about:
146+
// * Children being added/removed (childList)
147+
// Any id, class, or style attribute being changed (this approximates CSS changes)
148+
observer.observe(main, {
149+
childList: true,
150+
subtree: true,
151+
attributes: true,
152+
attributeFilter: ['class', 'id', 'style'],
153+
});
154+
155+
return () => observer.disconnect();
156+
}, [tocItems]);
157+
158+
// Mark the active item based on the scroll position
111159
useEffect(() => {
112-
if (tocItems.length === 0) {
160+
if (!tocItems.length) {
113161
return () => {};
114162
}
115163
// account for the header height

0 commit comments

Comments
 (0)