@@ -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