From 316625200e5e5df412c057f864c24150af80ef0c Mon Sep 17 00:00:00 2001 From: svelte-triage-bot Date: Mon, 31 Aug 2026 03:16:50 +0000 Subject: [PATCH 1/2] fix: avoid hashchange when resetting focus --- .changeset/calm-focus-rests.md | 5 ++ packages/kit/src/runtime/client/client.js | 78 +++++++------------ .../test/apps/hash-based-routing/test/test.js | 16 ++++ 3 files changed, 48 insertions(+), 51 deletions(-) create mode 100644 .changeset/calm-focus-rests.md diff --git a/.changeset/calm-focus-rests.md b/.changeset/calm-focus-rests.md new file mode 100644 index 000000000000..e061aa2997a8 --- /dev/null +++ b/.changeset/calm-focus-rests.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: avoid hashchange when resetting focus diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 27cf82375ec8..9b5c1e8550fd 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -188,16 +188,16 @@ function blur_active_element(reset) { * @param {Element | null} active_element */ function reset_scroll_and_focus(url, scroll, reset, active_element) { - /** @type {Element | null} */ - let deep_linked = null; - if (autoscroll) { if (scroll) { scrollTo(scroll.x, scroll.y); - } else if ((deep_linked = get_hash_element(url))) { - deep_linked.scrollIntoView(); } else { - scrollTo(0, 0); + const deep_linked = get_hash_element(url); + if (deep_linked) { + deep_linked.scrollIntoView(); + } else { + scrollTo(0, 0); + } } } @@ -205,7 +205,7 @@ function reset_scroll_and_focus(url, scroll, reset, active_element) { document.activeElement !== active_element && document.activeElement !== document.body; if (reset && !changed_focus) { - reset_focus(url, !deep_linked); + reset_focus(url); } autoscroll = true; @@ -3380,8 +3380,6 @@ function _start_router() { }); addEventListener('popstate', async (event) => { - if (resetting_focus) return; - const history_metadata = get_history_metadata(event.state); if (history_metadata?.historyIndex) { @@ -3779,17 +3777,25 @@ function deserialize_uses(uses) { }; } -/** - * This flag is used to avoid client-side navigation when we're only using - * `location.replace()` to set focus. - */ -let resetting_focus = false; +/** @param {Element} element */ +function focus_element(element) { + const tabindex = element.getAttribute('tabindex'); -/** - * @param {URL} url - * @param {boolean} [scroll] - */ -function reset_focus(url, scroll = true) { + element.setAttribute('tabindex', '-1'); + /** @type {HTMLElement} */ (element).focus({ preventScroll: true, focusVisible: false }); + + // Restoring `tabindex` synchronously prevents a focus ring in browsers without `focusVisible` + // and removes focus from elements that are not normally focusable, without resetting the + // sequential focus navigation starting point. + if (tabindex !== null) { + element.setAttribute('tabindex', tabindex); + } else { + element.removeAttribute('tabindex'); + } +} + +/** @param {URL} url */ +function reset_focus(url) { const autofocus = document.querySelector('[autofocus]'); if (autofocus) { // @ts-ignore @@ -3801,44 +3807,14 @@ function reset_focus(url, scroll = true) { // starting point to the fragment identifier. const element = get_hash_element(url); if (element) { - const { x, y } = scroll_state(); - - // `element.focus()` doesn't work on Safari and Firefox Ubuntu so we need - // to use this hack with `location.replace()` instead. - setTimeout(() => { - const history_state = history.state; - - resetting_focus = true; - location.replace(new URL(`#${element.id}`, location.href)); - - // Firefox has a bug that sets the history state to `null` so we need to - // restore it after. See https://bugzilla.mozilla.org/show_bug.cgi?id=1199924 - // This is also needed to restore the original hash if we're using hash routing - history.replaceState(history_state, '', url); - - // If scroll management has already happened earlier, we need to restore - // the scroll position after setting the sequential focus navigation starting point - if (scroll) scrollTo(x, y); - resetting_focus = false; - }); + focus_element(element); } else { // If the ID doesn't exist, we try to mimic browsers' behaviour as closely // as possible by targeting the first scrollable region. Unfortunately, it's // not a perfect match — e.g. shift-tabbing won't immediately cycle up from // the end of the page on Chromium // See https://html.spec.whatwg.org/multipage/interaction.html#get-the-focusable-area - const root = document.body; - const tabindex = root.getAttribute('tabindex'); - - root.tabIndex = -1; - root.focus({ preventScroll: true, focusVisible: false }); - - // restore `tabindex` as to prevent `root` from stealing input from elements - if (tabindex !== null) { - root.setAttribute('tabindex', tabindex); - } else { - root.removeAttribute('tabindex'); - } + focus_element(document.body); } // capture current selection, so we can compare the state after diff --git a/packages/kit/test/apps/hash-based-routing/test/test.js b/packages/kit/test/apps/hash-based-routing/test/test.js index 648d7cd27db8..ddef80016945 100644 --- a/packages/kit/test/apps/hash-based-routing/test/test.js +++ b/packages/kit/test/apps/hash-based-routing/test/test.js @@ -127,6 +127,22 @@ test.describe('hash based navigation', () => { await expect(page.locator('button[id="button3"]')).toBeFocused(); }); + test('resetting focus does not dispatch hashchange', async ({ page }) => { + await page.goto('/#/focus'); + await page.evaluate(() => { + window.hashchanges = []; + addEventListener('hashchange', (event) => { + window.hashchanges.push({ old_url: event.oldURL, new_url: event.newURL }); + }); + }); + + await page.locator('a[href="#/focus/a#p"]').click(); + await page.waitForURL('#/focus/a#p'); + await page.waitForTimeout(50); + + expect(await page.evaluate(() => window.hashchanges)).toEqual([]); + }); + test('does not look up an empty anchor id on navigation', async ({ page }) => { await page.addInitScript(` window.empty_id_lookups = []; From cb5e69040072630be54564cbe75d9aaa70466868 Mon Sep 17 00:00:00 2001 From: svelte-triage-bot Date: Mon, 31 Aug 2026 03:45:06 +0000 Subject: [PATCH 2/2] test: fix cross-browser hash routing focus assertions --- .../test/apps/hash-based-routing/test/test.js | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/kit/test/apps/hash-based-routing/test/test.js b/packages/kit/test/apps/hash-based-routing/test/test.js index ddef80016945..7a5e36f89174 100644 --- a/packages/kit/test/apps/hash-based-routing/test/test.js +++ b/packages/kit/test/apps/hash-based-routing/test/test.js @@ -118,21 +118,10 @@ test.describe('hash based navigation', () => { test('sequential focus navigation point is set correctly', async ({ page, browserName }) => { const tab = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; - await page.goto('/#/focus'); - await page.locator('a[href="#/focus/a#p"]').click(); - await page.waitForURL('#/focus/a#p'); - expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BODY'); - await page.keyboard.press(tab); - await expect(page.locator('#button3')).toBeFocused(); - await expect(page.locator('button[id="button3"]')).toBeFocused(); - }); - - test('resetting focus does not dispatch hashchange', async ({ page }) => { await page.goto('/#/focus'); await page.evaluate(() => { - window.hashchanges = []; - addEventListener('hashchange', (event) => { - window.hashchanges.push({ old_url: event.oldURL, new_url: event.newURL }); + addEventListener('hashchange', () => { + document.documentElement.dataset.hashchange = ''; }); }); @@ -140,7 +129,15 @@ test.describe('hash based navigation', () => { await page.waitForURL('#/focus/a#p'); await page.waitForTimeout(50); - expect(await page.evaluate(() => window.hashchanges)).toEqual([]); + await expect(page.locator('html')).not.toHaveAttribute('data-hashchange'); + if (browserName === 'chromium') { + expect(await page.evaluate(() => (document.activeElement || {}).nodeName)).toBe('BODY'); + } else { + await expect(page.locator('#p')).toBeFocused(); + } + await page.keyboard.press(tab); + await expect(page.locator('#button3')).toBeFocused(); + await expect(page.locator('button[id="button3"]')).toBeFocused(); }); test('does not look up an empty anchor id on navigation', async ({ page }) => {