Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/blur-svg-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: blur focused SVG elements before the DOM update on navigation
5 changes: 5 additions & 0 deletions .changeset/focus-popstate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: stop the popstate fired while resetting focus from reaching app listeners
48 changes: 24 additions & 24 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,25 @@ import { decode_pathname, strip_hash, make_trackable, normalize_path } from '../
import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js';
import { parse_routes, parse_server_route } from './parse.js';
import * as storage from './session-storage.js';
import { blur_active_element, is_resetting_focus, reset_focus } from './focus.js';
import { disable_scroll_handling, reset_scroll_and_focus } from './scroll.js';
import { blur_active_element, reset_focus } from './focus.js';
import {
capture_scroll,
delete_scroll,
disable_scroll_handling,
persist_scroll,
restore_scroll,
restore_stored_scroll,
stored_scroll
} from './scroll.js';
import {
find_anchor,
resolve_url,
get_link_info,
get_router_options,
get_fragment,
scroll_state,
is_external_url,
origin,
scroll_state,
load_css
} from './utils.js';
import { base, set_match_implementation } from '$app/paths/internal/client';
Expand Down Expand Up @@ -98,7 +107,7 @@ const resetters = new Set();
// popstate it's too late to access the options or update the focus position associated with the
// state we're navigating from
/**
* @type {Record<number, { scroll?: { x: number; y: number }; resetIndex?: number }>}
* @type {Record<number, { resetIndex?: number }>}
*/
const history_info = storage.get(HISTORY_INFO_KEY) ?? {};

Expand Down Expand Up @@ -156,11 +165,6 @@ if (DEV) {
};
}

/** @param {number} index */
function capture_scroll(index) {
history_info[index].scroll = scroll_state();
}

/**
* @param {number} index
* @param {Pick<HistoryMetadata, 'resetIndex'>} options
Expand All @@ -184,6 +188,7 @@ function clear_onward_history(current_history_index, current_navigation_index) {
let i = current_history_index + 1;
while (history_info[i]) {
delete history_info[i];
delete_scroll(i);
delete_navigation_snapshot(i);
i += 1;
}
Expand Down Expand Up @@ -538,12 +543,8 @@ async function _start(_app, _target, data) {

// if we reload the page, or Cmd-Shift-T back to it,
// recover scroll position
const scroll = history_info[current_history_index]?.scroll;
function restore_scroll() {
if (scroll) {
history.scrollRestoration = 'manual';
scrollTo(scroll.x, scroll.y);
}
if (restore_stored_scroll(current_history_index)) history.scrollRestoration = 'manual';
}

if (data) {
Expand Down Expand Up @@ -685,7 +686,7 @@ function restore_snapshot(index) {
}

function persist_state() {
capture_scroll(current_history_index);
persist_scroll(current_history_index);
storage.set(HISTORY_INFO_KEY, history_info);

capture_snapshot(current_navigation_index);
Expand Down Expand Up @@ -926,7 +927,7 @@ async function initialize(result, target, should_hydrate) {
from: null,
to: {
...nav,
scroll: history_info[current_history_index]?.scroll ?? scroll_state()
scroll: stored_scroll(current_history_index) ?? scroll_state()
},
willUnload: false,
type: 'enter',
Expand Down Expand Up @@ -2258,16 +2259,17 @@ async function run_on_navigate_callbacks(navigation) {
* @param {Promise<void> | undefined} updated
*/
async function finish_navigation(nav, nav_token, url, popped_scroll, reset, updated) {
const active_element = document.activeElement;

await updated;

if (navigation_token !== nav_token) {
nav.reject(new Error('navigation aborted'));
return false;
}

reset_scroll_and_focus(url, reset ? popped_scroll : scroll_state(), reset, active_element);
const deep_linked = restore_scroll(url, reset, popped_scroll);
if (reset && document.activeElement === document.body) {
reset_focus(url, !deep_linked);
}

is_navigating = false;

Expand Down Expand Up @@ -3243,7 +3245,7 @@ function _start_router() {
if (hash === '' || (hash === 'top' && a.ownerDocument.getElementById('top') === null)) {
scrollTo({ top: 0 });
} else {
const element = a.ownerDocument.getElementById(decodeURIComponent(hash));
const element = a.ownerDocument.getElementById(get_fragment(url, app.hash));
if (element) {
element.scrollIntoView();
element.focus();
Expand Down Expand Up @@ -3340,8 +3342,6 @@ function _start_router() {
});

addEventListener('popstate', async (event) => {
if (is_resetting_focus()) return;

const history_metadata = get_history_metadata(event.state);

if (history_metadata?.historyIndex) {
Expand All @@ -3356,7 +3356,7 @@ function _start_router() {
const delta = history_index - current_history_index;
const reset_index = history_metadata.resetIndex;
const reset = reset_index !== (source_info?.resetIndex ?? current_reset_index);
const scroll = history_info[history_index]?.scroll;
const scroll = stored_scroll(history_index);
const state = parse(history_metadata.state);
const url = new URL(history_metadata.pageUrl ?? location.href);
const navigation_index = history_metadata.navigationIndex;
Expand Down Expand Up @@ -3412,7 +3412,7 @@ function _start_router() {

update_url(url);

if (reset && scroll) scrollTo(scroll.x, scroll.y);
if (reset) restore_stored_scroll(history_index);
restore_navigation_snapshot(current_history_index, current_registrations());
return;
}
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/runtime/client/constants.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const SNAPSHOT_KEY = 'sveltekit:snapshot';
export const NAVIGATION_SNAPSHOT_KEY = 'sveltekit:navigation-snapshot';
export const HISTORY_INFO_KEY = 'sveltekit:history-info';
export const SCROLL_KEY = 'sveltekit:scroll';
export const HISTORY_METADATA_KEY = 'sveltekit:metadata';

export const PRELOAD_PRIORITIES = /** @type {const} */ ({
Expand Down
32 changes: 18 additions & 14 deletions packages/kit/src/runtime/client/focus.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@ import { get_hash_element, scroll_state } from './utils.js';
*/
let resetting_focus = false;

export function is_resetting_focus() {
return resetting_focus;
}
// setting focus below fires a popstate that is not a navigation; swallow it before any listener sees it
addEventListener('popstate', (event) => resetting_focus && event.stopImmediatePropagation(), true);

/** @param {boolean} reset */
/**
* Blurs the active element before the DOM update when a navigation resets focus, so that
* blur/focusout handlers run while the outgoing component's data is still valid (#14575)
* @param {boolean} reset
*/
export function blur_active_element(reset) {
const element = document.activeElement;

if (
reset &&
document.activeElement instanceof HTMLElement &&
document.activeElement !== document.body
(element instanceof HTMLElement || element instanceof SVGElement) &&
element !== document.body
) {
document.activeElement.blur();
element.blur();
}
}

Expand All @@ -40,17 +45,16 @@ export function reset_focus(url, scroll = true) {
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.
// focusing a non-focusable element is a no-op, so navigate to the fragment
// instead; see sveltejs/kit#16982 for the tabindex alternative
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
// a fragment navigation nulls `history.state` (per spec; WebKit keeps it), so
// restore it. This also restores 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
Expand Down Expand Up @@ -97,8 +101,8 @@ export function reset_focus(url, scroll = true) {
const a = ranges[i];
const b = selection.getRangeAt(i);

// we need to do a deep comparison rather than just `a !== b` because
// Safari behaves differently to other browsers
// compare field by field: a range modified in place keeps its identity,
// and Safari before 17 returned a new Range object on every getRangeAt()
if (
a.commonAncestorContainer !== b.commonAncestorContainer ||
a.startContainer !== b.startContainer ||
Expand Down
12 changes: 11 additions & 1 deletion packages/kit/src/runtime/client/focus.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { reset_focus } from './focus.js';
import { blur_active_element, reset_focus } from './focus.js';

beforeEach(() => {
window.scrollTo = vi.fn();
Expand All @@ -10,6 +10,16 @@ afterEach(() => {
vi.useRealTimers();
});

test('blur_active_element blurs a focused SVG element', () => {
document.body.innerHTML = '<svg tabindex="0"></svg>';
const svg = /** @type {SVGElement} */ (document.body.firstElementChild);
svg.focus();
expect(document.activeElement).toBe(svg);

blur_active_element(true);
expect(document.activeElement).toBe(document.body);
});

test('reset_focus focuses the body without leaving a tabindex behind', () => {
document.body.innerHTML = '<input>';
reset_focus(new URL('/', location.href));
Expand Down
66 changes: 51 additions & 15 deletions packages/kit/src/runtime/client/scroll.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,75 @@
import { hash_routing } from '$app/paths/internal/client';
import { reset_focus } from './focus.js';
import { get_hash_element } from './utils.js';
import * as storage from './session-storage.js';
import { SCROLL_KEY } from './constants.js';
import { get_hash_element, scroll_state } from './utils.js';

/** @typedef {{ x: number; y: number }} ScrollPosition */

/** scroll positions by history index, restored on popstate and reload */
/** @type {Record<number, ScrollPosition>} */
const positions = storage.get(SCROLL_KEY) ?? {};

let autoscroll = true;

/** @param {number} index */
export function capture_scroll(index) {
positions[index] = scroll_state();
}

/** @param {number} index */
export function persist_scroll(index) {
capture_scroll(index);
storage.set(SCROLL_KEY, positions);
}

/** @param {number} index */
export function delete_scroll(index) {
delete positions[index];
}

/** @param {number} index */
export function stored_scroll(index) {
return positions[index];
}

/**
* Scrolls to the position stored for `index`, if any
* @param {number} index
*/
export function restore_stored_scroll(index) {
const position = positions[index];
if (position) scrollTo(position.x, position.y);
return !!position;
}

/** Disables scroll handling for the next `restore_scroll` */
export function disable_scroll_handling() {
autoscroll = false;
}

/**
* After a navigation that resets, scrolls to `popped_scroll` ?? the hash target ?? the top,
* unless `disable_scroll_handling` was called since the last navigation
* @param {URL} url
* @param {{ x: number; y: number } | null | undefined} scroll
* @param {boolean} reset
* @param {Element | null} active_element
* @param {ScrollPosition | null | undefined} popped_scroll
* @returns {Element | null} the hash target, when that is what was scrolled into view
*/
export function reset_scroll_and_focus(url, scroll, reset, active_element) {
export function restore_scroll(url, reset, popped_scroll) {
/** @type {Element | null} */
let deep_linked = null;

if (autoscroll) {
if (scroll) {
scrollTo(scroll.x, scroll.y);
if (reset && autoscroll) {
if (popped_scroll) {
scrollTo(popped_scroll.x, popped_scroll.y);
} else if ((deep_linked = get_hash_element(url, hash_routing))) {
deep_linked.scrollIntoView();
} else {
scrollTo(0, 0);
}
}

const changed_focus =
document.activeElement !== active_element && document.activeElement !== document.body;

if (reset && !changed_focus) {
reset_focus(url, !deep_linked);
}

autoscroll = true;

return deep_linked;
}
Loading
Loading