Skip to content

Commit 189155c

Browse files
committed
fix: scroll race condition in hash navigation
1 parent 73bec1a commit 189155c

File tree

2 files changed

+120
-168
lines changed

2 files changed

+120
-168
lines changed

src/client/ConfigNavigationClient.js

Lines changed: 52 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,9 @@
1313

1414
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
1515

16-
// ============================================================================
17-
// State Management
18-
// ============================================================================
19-
2016
let isInitialized = false;
2117
let previousHash = null;
2218

23-
// ============================================================================
24-
// Helper Functions
25-
// ============================================================================
26-
27-
/**
28-
* Get all parent details elements for a given element
29-
*/
3019
const getParentDetailsElements = function(element) {
3120
const parents = [];
3221
let current = element;
@@ -39,14 +28,11 @@ const getParentDetailsElements = function(element) {
3928
return parents;
4029
};
4130

42-
/**
43-
* Open a details element and its collapsible content
44-
*/
4531
const openDetailsElement = function(detailsEl) {
4632
if (detailsEl && detailsEl.tagName === 'DETAILS' && !detailsEl.open) {
4733
detailsEl.open = true;
4834
detailsEl.setAttribute('data-collapsed', 'false');
49-
// Expand the collapsible content by removing inline styles
35+
// Expand collapsible content by removing inline styles
5036
const collapsibleContent = detailsEl.querySelector(':scope > div[style]');
5137
if (collapsibleContent) {
5238
collapsibleContent.style.display = 'block';
@@ -56,19 +42,13 @@ const openDetailsElement = function(detailsEl) {
5642
}
5743
};
5844

59-
/**
60-
* Close a details element
61-
*/
6245
const closeDetailsElement = function(detailsEl) {
6346
if (detailsEl && detailsEl.tagName === 'DETAILS' && detailsEl.open) {
6447
detailsEl.open = false;
6548
detailsEl.setAttribute('data-collapsed', 'true');
6649
}
6750
};
6851

69-
/**
70-
* Close all details except those in the keep-open set
71-
*/
7252
const closeOtherDetails = function(keepOpenSet) {
7353
document.querySelectorAll('details.config-field[open]').forEach(function(el) {
7454
if (!keepOpenSet.has(el)) {
@@ -77,22 +57,15 @@ const closeOtherDetails = function(keepOpenSet) {
7757
});
7858
};
7959

80-
// ============================================================================
81-
// Core Navigation Functions
82-
// ============================================================================
83-
84-
/**
85-
* Highlight sidebar links based on current hash
86-
*/
87-
const highlightActiveOnPageLink = function() {
88-
if (!location.hash) {
60+
const highlightActiveOnPageLink = function(hash) {
61+
const hashToUse = hash || location.hash;
62+
if (!hashToUse) {
8963
return;
9064
}
9165

92-
const activeHash = location.hash.substring(1);
66+
const activeHash = hashToUse.substring(1);
9367
const allLinks = document.querySelectorAll('a');
9468

95-
// Remove active class from all links
9669
for (let i = 0; i < allLinks.length; i++) {
9770
const link = allLinks[i];
9871
link.classList.remove('active');
@@ -102,27 +75,21 @@ const highlightActiveOnPageLink = function() {
10275
}
10376
}
10477

105-
// Add active class to links matching current hash
10678
const activeLinks = document.querySelectorAll("a[href='#" + activeHash + "']");
10779
for (let i = 0; i < activeLinks.length; i++) {
10880
activeLinks[i].classList.add('active');
10981
}
11082
};
11183

112-
/**
113-
* Highlight and expand details elements containing the active hash
114-
*/
11584
const highlightDetailsOnActiveHash = function(activeHash, doNotOpen) {
11685
const activeAnchors = document.querySelectorAll(".anchor[id='" + activeHash + "']");
11786
const detailsElements = document.querySelectorAll('details');
11887
const activeSectionElements = document.querySelectorAll('.active-section');
11988

120-
// Remove active-section class from all elements
12189
for (let i = 0; i < activeSectionElements.length; i++) {
12290
activeSectionElements[i].classList.remove('active-section');
12391
}
12492

125-
// Add active-section class to elements following the active anchor
12693
for (let i = 0; i < activeAnchors.length; i++) {
12794
const headline = activeAnchors[i].parentElement;
12895
const headlineRank = activeAnchors[i].parentElement.nodeName.substr(1);
@@ -143,12 +110,10 @@ const highlightDetailsOnActiveHash = function(activeHash, doNotOpen) {
143110
}
144111
}
145112

146-
// Remove active class from all details
147113
for (let i = 0; i < detailsElements.length; i++) {
148114
detailsElements[i].classList.remove('active');
149115
}
150116

151-
// Add active class and open parent details
152117
if (activeAnchors.length > 0) {
153118
for (let i = 0; i < activeAnchors.length; i++) {
154119
let element = activeAnchors[i];
@@ -172,18 +137,15 @@ const highlightDetailsOnActiveHash = function(activeHash, doNotOpen) {
172137
}
173138
}
174139

175-
// Handle elements with matching IDs (for nested config fields)
176140
const targetElement = activeHash ? document.getElementById(activeHash) : null;
177141
if (targetElement) {
178142
const parentDetails = getParentDetailsElements(targetElement);
179143
const keepOpenSet = new Set(parentDetails);
180144

181-
// Close all other details if not in doNotOpen mode
182145
if (!doNotOpen) {
183146
closeOtherDetails(keepOpenSet);
184147
}
185148

186-
// Process parent details
187149
for (let i = 0; i < parentDetails.length; i++) {
188150
const element = parentDetails[i];
189151
element.classList.add('active');
@@ -201,99 +163,100 @@ const highlightDetailsOnActiveHash = function(activeHash, doNotOpen) {
201163
}
202164
};
203165

204-
/**
205-
* Handle hash navigation - expand details and highlight links
206-
*/
166+
// NOTE: This does NOT scroll - scrolling is handled by DetailsClicksClient
207167
const handleHashNavigation = function(hash) {
208168
if (!hash) return;
209169

210170
const targetId = hash.substring(1);
211-
212-
// Expand parent details elements
213171
highlightDetailsOnActiveHash(targetId);
214-
215-
// Highlight active sidebar link
216172
highlightActiveOnPageLink();
217-
218-
// Scroll to target (with delay for animation)
219-
setTimeout(() => {
220-
const element = document.getElementById(targetId);
221-
if (element) {
222-
const yOffset = -280;
223-
const y = element.getBoundingClientRect().top + window.scrollY + yOffset;
224-
window.scrollTo({ top: y, behavior: 'smooth' });
225-
}
226-
}, 100);
227173
};
228174

229-
/**
230-
* Initialize event handlers for hash links and history navigation
231-
* Hash link clicks are handled by DetailsClicksClient to avoid race conditions
232-
*/
175+
// Hash link clicks are handled by DetailsClicksClient to avoid race conditions
233176
const initializeEventHandlers = function() {
234177
// Empty - event handlers managed by DetailsClicksClient
235178
};
236179

237-
// ============================================================================
238-
// Docusaurus Lifecycle Hooks
239-
// ============================================================================
240-
241180
/**
242-
* Called on client-side navigation (before DOM update)
181+
* Docusaurus lifecycle hook - called before DOM update
243182
*/
244183
export function onRouteUpdate({ location, previousLocation }) {
245-
// Track previous hash for comparison
246184
if (previousLocation) {
247185
previousHash = previousLocation.hash;
248186
}
249187
}
250188

251189
/**
252-
* Called after route has updated and DOM is ready
253-
* This is where we safely manipulate DOM after React hydration
190+
* Docusaurus lifecycle hook - called after route updates and DOM is ready
191+
* Safely manipulates DOM after React hydration
254192
*/
255193
export function onRouteDidUpdate({ location, previousLocation }) {
256194
if (!ExecutionEnvironment.canUseDOM) {
257195
return;
258196
}
259197

260-
// Initialize on first load
261198
if (!isInitialized) {
262199
isInitialized = true;
263200

264-
// Wait for React hydration to complete
265201
requestAnimationFrame(() => {
266202
requestAnimationFrame(() => {
267-
// Initialize event handlers
268203
initializeEventHandlers();
269204

270-
// Handle initial hash if present
271205
if (location.hash) {
206+
const targetId = location.hash.substring(1);
207+
const targetEl = document.getElementById(targetId);
208+
272209
handleHashNavigation(location.hash);
210+
211+
if (targetEl) {
212+
setTimeout(() => {
213+
const y = targetEl.getBoundingClientRect().top + window.scrollY - 280;
214+
window.scrollTo({ top: y, behavior: 'smooth' });
215+
}, 150);
216+
}
273217
}
274218
});
275219
});
276220

277-
// Set up browser back/forward navigation handler
221+
// For popstate, we DO want to scroll since DetailsClicksClient isn't involved
278222
window.addEventListener('popstate', function() {
279-
handleHashNavigation(location.hash);
223+
const hash = location.hash;
224+
if (!hash) return;
225+
226+
const targetId = hash.substring(1);
227+
const targetEl = document.getElementById(targetId);
228+
229+
handleHashNavigation(hash);
230+
231+
if (targetEl) {
232+
setTimeout(() => {
233+
const y = targetEl.getBoundingClientRect().top + window.scrollY - 280;
234+
window.scrollTo({ top: y, behavior: 'smooth' });
235+
}, 150);
236+
}
280237
});
281238

282-
// Set up hashchange handler (triggered by internal navigation)
283-
window.addEventListener('hashchange', function() {
284-
highlightActiveOnPageLink();
285-
handleHashNavigation(location.hash);
239+
// NOTE: hashchange only handles highlighting, NOT scrolling (to avoid race condition)
240+
// Scrolling is handled by DetailsClicksClient's universal click handler
241+
window.addEventListener('hashchange', function(e) {
242+
const newHash = e.newURL ? e.newURL.split('#')[1] : '';
243+
const hashToUse = newHash ? '#' + newHash : location.hash;
244+
245+
if (!hashToUse) {
246+
return;
247+
}
248+
249+
const targetId = hashToUse.substring(1);
250+
highlightActiveOnPageLink(hashToUse);
251+
highlightDetailsOnActiveHash(targetId);
286252
});
287253

288254
return;
289255
}
290256

291-
// Handle hash changes on subsequent navigations
292-
if (location.hash !== previousHash) {
293-
handleHashNavigation(location.hash);
294-
previousHash = location.hash;
295-
}
296-
297-
// Re-initialize event handlers for newly rendered elements
257+
// Hash changes on subsequent navigations are handled by:
258+
// - hashchange listener (for browser back/forward, URL bar edits)
259+
// - DetailsClicksClient click handler (for link clicks)
260+
previousHash = location.hash;
298261
initializeEventHandlers();
299262
}

0 commit comments

Comments
 (0)