Skip to content

Commit b3d901f

Browse files
authored
Fix search ?q= scroll overriding hash-fragment navigation (#14141)
When clicking a search result linking to a specific section (e.g., page.html?q=term#section), scrollToFirstVisibleMatch() overrode the browser's hash scroll, jumping to the first <mark> near the top. Additionally, quarto-nav.js baked ?q= into all link hrefs during DOMContentLoaded, causing TOC clicks to trigger full-page reloads. Two fixes: - Move replaceState (removing ?q=) to module load time, before any DOMContentLoaded handlers run, so quarto-nav.js resolves link hrefs against a clean URL - Guard scrollToFirstVisibleMatch with a hash check so browser hash navigation is preserved for section-level search results
1 parent 0a50aa5 commit b3d901f

5 files changed

Lines changed: 69 additions & 10 deletions

File tree

src/resources/projects/website/search/quarto-search.js

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ const kResultsArg = "show-results";
88
// item is a more item (along with the type) and can be handled appropriately
99
const kItemTypeMoreHref = "0767FDFD-0422-4E5A-BC8A-3BE11E5BBA05";
1010

11+
// Capture search params and clean ?q= from URL at module load time, before
12+
// any DOMContentLoaded handlers run. quarto-nav.js resolves all <a> hrefs
13+
// against window.location during DOMContentLoaded — if ?q= is still present,
14+
// every link on the page gets the query param baked into its href.
15+
const currentUrl = new URL(window.location);
16+
const kQuery = currentUrl.searchParams.get(kQueryArg);
17+
if (kQuery) {
18+
const replacementUrl = new URL(window.location);
19+
replacementUrl.searchParams.delete(kQueryArg);
20+
window.history.replaceState({}, "", replacementUrl);
21+
}
22+
1123
window.document.addEventListener("DOMContentLoaded", function (_event) {
1224
// Ensure that search is available on this page. If it isn't,
1325
// should return early and not do anything
@@ -37,14 +49,12 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
3749
// Used to determine highlighting behavior for this page
3850
// A `q` query param is expected when the user follows a search
3951
// to this page
40-
const currentUrl = new URL(window.location);
41-
const query = currentUrl.searchParams.get(kQueryArg);
52+
const query = kQuery;
4253
const showSearchResults = currentUrl.searchParams.get(kResultsArg);
4354
const mainEl = window.document.querySelector("main");
4455

4556
// highlight matches on the page
4657
if (query && mainEl) {
47-
// perform any highlighting
4858
highlight(query, mainEl);
4959

5060
// Activate tabs on pageshow — after tabsets.js restores localStorage state.
@@ -56,14 +66,13 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
5666
for (const mark of mainEl.querySelectorAll("mark")) {
5767
openAllTabsetsContainingEl(mark);
5868
}
59-
requestAnimationFrame(() => scrollToFirstVisibleMatch(mainEl));
69+
// Only scroll to first match when there's no hash fragment.
70+
// With a hash, the browser already scrolled to the target section.
71+
if (!currentUrl.hash) {
72+
requestAnimationFrame(() => scrollToFirstVisibleMatch(mainEl));
73+
}
6074
}
6175
}, { once: true });
62-
63-
// fix up the URL to remove the q query param
64-
const replacementUrl = new URL(window.location);
65-
replacementUrl.searchParams.delete(kQueryArg);
66-
window.history.replaceState({}, "", replacementUrl);
6776
}
6877

6978
// function to clear highlighting on the page when the search query changes

tests/docs/playwright/html/search-highlight/_quarto.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ website:
88
- href: index.qmd
99
text: Home
1010

11-
format: html
11+
format:
12+
html:
13+
toc: true

tests/docs/playwright/html/search-highlight/index.qmd

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
title: "Search Highlight Test"
33
---
44

5+
## First Section
6+
57
This page contains a special keyword that we use for testing search highlighting.
68

9+
## Second Section
10+
711
The word special appears multiple times on this page to ensure search highlighting works correctly.
12+
13+
## Third Section
14+
15+
More content here for navigation testing.

tests/integration/playwright/tests/html-search-highlight.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,22 @@ test('Search highlights cleared when query changes', async ({ page }) => {
3232
await expect(page.locator('main mark')).toHaveCount(0, { timeout: 2000 });
3333
});
3434

35+
test('TOC links do not retain search query parameter', async ({ page }) => {
36+
await page.goto(`${BASE}?q=special`);
37+
await page.waitForSelector('mark');
38+
39+
// Check that sidebar/TOC links don't contain ?q=
40+
const tocLinks = page.locator('#TOC a[href], .sidebar-navigation a[href]');
41+
const count = await tocLinks.count();
42+
expect(count).toBeGreaterThan(0);
43+
for (let i = 0; i < count; i++) {
44+
const href = await tocLinks.nth(i).getAttribute('href');
45+
if (href) {
46+
expect(href).not.toContain('q=special');
47+
}
48+
}
49+
});
50+
3551
test('No highlights without search query', async ({ page }) => {
3652
await page.goto(BASE);
3753

tests/integration/playwright/tests/html-search-tabsets.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,30 @@ test('Search activation overrides localStorage tab preference', async ({ page })
104104
expect(await visibleMarkCount(page)).toBe(1);
105105
});
106106

107+
test('Search with hash fragment scrolls to target section, not first match', async ({ page }) => {
108+
// Use a very small viewport so mark and hash target can't both be visible
109+
await page.setViewportSize({ width: 800, height: 200 });
110+
// Navigate with ?q= matching near the top AND #hash pointing to section further down
111+
await page.goto(`${BASE}?q=beta-unique-search-term#grouped-tabset`);
112+
113+
// Marks should exist (highlighting works)
114+
const marks = page.locator('mark');
115+
await expect(marks.first()).toBeVisible({ timeout: 5000 });
116+
117+
// Wait for all scroll behavior to settle (rAF + smooth scroll animation)
118+
await page.waitForFunction(() => {
119+
return new Promise<boolean>(resolve => {
120+
requestAnimationFrame(() => requestAnimationFrame(() => {
121+
setTimeout(() => resolve(true), 800);
122+
}));
123+
});
124+
});
125+
126+
// The hash target section should still be in viewport (not scrolled away to first mark)
127+
const section = page.locator('#grouped-tabset');
128+
await expect(section).toBeInViewport();
129+
});
130+
107131
test('Search scrolls to first visible match', async ({ page }) => {
108132
// Use small viewport so the nested tabset at the bottom is below the fold,
109133
// ensuring the test actually exercises scrollIntoView (not trivially passing).

0 commit comments

Comments
 (0)