From e612de256808335d4e40881dd40105ab41f8d91a Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 17 Jul 2026 16:47:03 +0200 Subject: [PATCH 01/29] breaking: adjust how `pushState/replaceState` behave There have been a few confusions with how shallow routing and `pushState/replaceState` work. It was several parts, which are all addressed in this PR: - `page.url/params/route` are not updated. This still is that way, but the new `page.shallow.url/params/route` object now gives you that information. Closes #10661 - people want to preserve history state across full page reloads. This was previously not done because `pushState/replaceState` were only concerned with shallow routing. The case of "I just wanna set some history state" was basically overlooked. This changes: `pushState/replaceState('', ...)` does _not_ enter shallow routing mode if you are not already in it. And a new third argument to these two functions makes it possible to preserve state across reloads, e.g. `pushState('', { survives: 'yay' }, { persist: true })`. `goto` will always persist state. Closes #13293, closes #11956 - Shallow routing did not trigger navigation hooks (before/after/onNavigate). Now they do. If you don't want that because you just wanna set some state, use `pushState('', ...)`. Closes #11759, closes #11776 - As a an additional DX-win, you can now also do `pushState/replaceState(null, ...)` which basically means "end shallow routing mode and revert the visible url to what it was before" --- .changeset/fresh-shallows-navigate.md | 5 + .changeset/late-lands-watch.md | 5 + .../docs/30-advanced/67-shallow-routing.md | 26 ++- packages/kit/src/exports/public.d.ts | 25 ++- packages/kit/src/runtime/app/state/client.js | 3 + packages/kit/src/runtime/app/state/index.js | 2 +- packages/kit/src/runtime/app/state/server.js | 3 + packages/kit/src/runtime/client/client.js | 210 +++++++++++++----- packages/kit/src/runtime/client/constants.js | 1 + .../kit/src/runtime/client/state.svelte.js | 1 + .../kit/src/runtime/server/page/render.js | 1 + packages/kit/src/types/ambient.d.ts | 2 +- packages/kit/test/apps/basics/src/app.d.ts | 11 + .../shallow-routing/push-state/+layout.svelte | 45 ++++ .../shallow-routing/push-state/+page.svelte | 39 +++- .../push-state/[param]/+page.svelte | 1 + .../shallow-routing/push-state/a/+page.svelte | 1 + .../replace-state/+layout.svelte | 27 +++ .../replace-state/+page.svelte | 15 +- .../replace-state/effect/+page.svelte | 2 +- .../kit/test/apps/basics/test/client.test.js | 203 ++++++++++++++++- packages/kit/types/index.d.ts | 47 +++- 22 files changed, 585 insertions(+), 90 deletions(-) create mode 100644 .changeset/fresh-shallows-navigate.md create mode 100644 .changeset/late-lands-watch.md create mode 100644 packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/[param]/+page.svelte diff --git a/.changeset/fresh-shallows-navigate.md b/.changeset/fresh-shallows-navigate.md new file mode 100644 index 000000000000..98f8eca18620 --- /dev/null +++ b/.changeset/fresh-shallows-navigate.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +breaking: adjust how `pushState/replaceState` behave diff --git a/.changeset/late-lands-watch.md b/.changeset/late-lands-watch.md new file mode 100644 index 000000000000..6f52cfba61ad --- /dev/null +++ b/.changeset/late-lands-watch.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: provide possibility to preserve state across reloads when using `pushState/replaceState` diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index 09dbd8e4f178..cf8095f41937 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -15,8 +15,8 @@ SvelteKit makes this possible with the [`pushState`]($app-navigation#pushState) import { page } from '$app/state'; import Modal from './Modal.svelte'; - function showModal() { - pushState('', { + async function showModal() { + await pushState('', { showModal: true }); } @@ -31,11 +31,25 @@ The modal can be dismissed by navigating back (unsetting `page.state.showModal`) ## API -The first argument to `pushState` is the URL, relative to the current URL. To stay on the current URL, use `''`. +The first argument to `pushState` is the URL, relative to the current URL. A non-empty URL performs a shallow navigation: [`beforeNavigate`]($app-navigation#beforeNavigate), [`onNavigate`]($app-navigation#onNavigate), [`onNavigate`]($app-navigation#onNavigate), [`onNavigate`]($app-navigation#onNavigate) and [`afterNavigate`]($app-navigation#afterNavigate) will run with `navigation.type === 'shallow'`. + +Once shallow routing is active, `page.shallow` is set with the URL, parameters and route id that is user-visible. `page.url`, `page.params` and `page.route` remain as they were before the shallow navigation. + +To keep the current URL, use `''`. This only updates history and page state, and does not call navigation lifecycle functions. If the page is already showing a shallow route, `page.shallow` is preserved; otherwise it remains `null`. + +Passing `null` as the first argument will exit shallow routing (as do `goto` or link clicks). This reverts the visible URL to what it was before and sets `page.shallow` to `null`. The second argument is the new page state, which can be accessed via the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). -To set page state without creating a new history entry, use `replaceState` instead of `pushState`. +After a shallow navigation, `page.shallow` contains the target `url`, `params` and `route`. The actual page has not changed, so `page.url`, `page.params` and `page.route` continue to describe the page that is currently rendered. + +`pushState` and `replaceState` return promises that resolve after the page state change has been applied to the DOM. To set page state without creating a new history entry, use `replaceState` instead of `pushState`. + +By default, state set through `pushState` or `replaceState` is discarded on a full page reload. Pass `{ persist: true }` as the third argument to restore it in the browser after reloading, independently of whether the call performs a shallow navigation: + +```js +await pushState('', { showModal: true }, { persist: true }); +``` ## Loading data for a route @@ -74,7 +88,7 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c const result = await preloadData(href); if (result.type === 'loaded' && result.status === 200) { - pushState(href, { selected: result.data }); + await pushState(href, { selected: result.data }); } else { // something bad happened! try navigating goto(href); @@ -96,6 +110,6 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c ## Caveats -During server-side rendering, `page.state` is always an empty object. The same is true for the first page the user lands on — if the user reloads the page (or returns from another document), state will _not_ be applied until they navigate. +During server-side rendering, `page.state` is always an empty object. State set through `goto` is always restored in the browser after a reload. State set through `pushState` or `replaceState` is only restored if `{ persist: true }` was passed. Shallow routing is a feature that requires JavaScript to work. Be mindful when using it and try to think of sensible fallback behavior in case JavaScript isn't available. diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index bbdc331cec80..09fa6045d45b 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1268,8 +1268,9 @@ export interface NavigationTarget< * - `leave`: The app is being left either because the tab is being closed or a navigation to a different document is occurring * - `link`: Navigation was triggered by a link click * - `popstate`: Navigation was triggered by back/forward navigation + * - `shallow`: Navigation was triggered by a `pushState(...)` or `replaceState(...)` call with a non-empty URL */ -export type NavigationType = 'enter' | 'form' | 'leave' | 'link' | 'goto' | 'popstate'; +export type NavigationType = 'enter' | 'form' | 'leave' | 'link' | 'goto' | 'popstate' | 'shallow'; export interface NavigationBase { /** @@ -1280,6 +1281,7 @@ export interface NavigationBase { * - `leave`: The app is being left either because the tab is being closed or a navigation to a different document is occurring * - `link`: Navigation was triggered by a link click * - `popstate`: Navigation was triggered by back/forward navigation + * - `shallow`: Navigation was triggered by a `pushState(...)` or `replaceState(...)` call with a non-empty URL */ type: NavigationType; /** @@ -1327,6 +1329,13 @@ export interface NavigationGoto extends NavigationBase { type: 'goto'; } +/** + * A navigation triggered by a `pushState(...)` or `replaceState(...)` call with a non-empty URL + */ +export interface NavigationShallow extends NavigationBase { + type: 'shallow'; +} + /** * A navigation triggered by the tab being closed, or the user navigating to a different document */ @@ -1379,7 +1388,8 @@ export type Navigation = | NavigationExternal | NavigationFormSubmit | NavigationPopState - | NavigationLink; + | NavigationLink + | NavigationShallow; /** * The argument passed to [`beforeNavigate`](https://svelte.dev/docs/kit/$app-navigation#beforeNavigate) callbacks. @@ -1453,6 +1463,17 @@ export interface Page< * The page state, which can be manipulated using the [`pushState`](https://svelte.dev/docs/kit/$app-navigation#pushState) and [`replaceState`](https://svelte.dev/docs/kit/$app-navigation#replaceState) functions from `$app/navigation`. */ state: App.PageState; + /** + * Information about the target of the most recent shallow navigation, or `null` if no shallow navigation has occurred. + */ + shallow: { + /** Parameters of the target route, or `null` if the URL does not resolve to a route. */ + params: AppLayoutParams<'/'> | null; + /** Info about the target route, or `null` if the URL does not resolve to a route. */ + route: { id: AppRouteId } | null; + /** The normalized URL passed to `pushState` or `replaceState`. */ + url: ReadonlyURL; + } | null; /** * Filled only after a form submission. See [form actions](https://svelte.dev/docs/kit/form-actions) for more info. */ diff --git a/packages/kit/src/runtime/app/state/client.js b/packages/kit/src/runtime/app/state/client.js index 7abed9d50169..9b76fb9e2d32 100644 --- a/packages/kit/src/runtime/app/state/client.js +++ b/packages/kit/src/runtime/app/state/client.js @@ -20,6 +20,9 @@ export const page = { get route() { return _page.route; }, + get shallow() { + return _page.shallow; + }, get state() { return _page.state; }, diff --git a/packages/kit/src/runtime/app/state/index.js b/packages/kit/src/runtime/app/state/index.js index 70ed0eaab425..bd1153fac6ac 100644 --- a/packages/kit/src/runtime/app/state/index.js +++ b/packages/kit/src/runtime/app/state/index.js @@ -15,7 +15,7 @@ import { BROWSER } from 'esm-env'; * - retrieving the combined `data` of all pages/layouts anywhere in your component tree (also see [loading data](https://svelte.dev/docs/kit/load)) * - retrieving the current value of the `form` prop anywhere in your component tree (also see [form actions](https://svelte.dev/docs/kit/form-actions)) * - retrieving the page state that was set through `goto`, `pushState` or `replaceState` (also see [goto](https://svelte.dev/docs/kit/$app-navigation#goto) and [shallow routing](https://svelte.dev/docs/kit/shallow-routing)) - * - retrieving metadata such as the URL you're on, the current route and its parameters, and whether or not there was an error + * - retrieving metadata such as the URL you're on, the current route and its parameters, the target of a shallow navigation, and whether or not there was an error * * ```svelte * diff --git a/packages/kit/src/runtime/app/state/server.js b/packages/kit/src/runtime/app/state/server.js index 0be169314fc4..5c0e703b692a 100644 --- a/packages/kit/src/runtime/app/state/server.js +++ b/packages/kit/src/runtime/app/state/server.js @@ -33,6 +33,9 @@ export const page = { get route() { return (DEV ? context_dev('page.route') : context()).page.route; }, + get shallow() { + return (DEV ? context_dev('page.shallow') : context()).page.shallow; + }, get state() { return (DEV ? context_dev('page.state') : context()).page.state; }, diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 1edc63853a4c..ff4459540cac 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -5,7 +5,7 @@ /** @import { Query } from './remote-functions/query/instance.svelte.js' */ /** @import { LiveQuery } from './remote-functions/query-live/instance.svelte.js' */ import { BROWSER, DEV } from 'esm-env'; -import * as svelte from 'svelte'; +import { settled, tick, fork, onMount, untrack } from 'svelte'; import { HttpError, Redirect, SvelteKitError } from '@sveltejs/kit/internal'; import { decode_pathname, strip_hash, make_trackable, normalize_path } from '../../utils/url.js'; import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js'; @@ -29,6 +29,7 @@ import { PRELOAD_PRIORITIES, SCROLL_KEY, STATES_KEY, + STATES_PERSISTED_KEY, SNAPSHOT_KEY, PAGE_URL_KEY } from './constants.js'; @@ -415,7 +416,8 @@ export async function start(_app, _target, hydrate) { await navigate({ type: 'enter', url: resolve_url(app.hash ? decode_hash(new URL(location.href)) : location.href), - replace_state: true + replace_state: true, + state: history.state?.[STATES_PERSISTED_KEY] ? (history.state[STATES_KEY] ?? {}) : {} }); restore_scroll(); @@ -464,6 +466,7 @@ async function _invalidate(reset_page_state = true) { } const prev_state = page.state; + const prev_shallow = page.shallow; const navigation_result = intent && (await load_route(intent)); if (!navigation_result || token !== invalidation_token || nav_token !== navigation_token) { return; @@ -488,6 +491,7 @@ async function _invalidate(reset_page_state = true) { if (!reset_page_state) { navigation_result.props.page.state = prev_state; } + navigation_result.props.page.shallow = prev_shallow; update(navigation_result.props.page); current_tree = navigation_result.props.tree; current = { ...navigation_result.state, nav: current.nav }; @@ -603,9 +607,8 @@ export async function _goto(url, options, redirect_count, nav_token, intent) { if (options.refreshAll) { // TODO the ticks shouldn't be necessary, something inside Svelte itself is buggy // when a query in a layout that still exists after page change is refreshed earlier than this - void svelte - .tick() - .then(svelte.tick) + void tick() + .then(tick) .then(() => { for (const [id, entries] of query_map) { for (const [payload, { resource }] of entries) { @@ -647,7 +650,7 @@ async function _preload_data(intent) { load_cache.promise.catch(discard_load_cache); - if (__SVELTEKIT_FORK_PRELOADS__ && svelte.fork) { + if (__SVELTEKIT_FORK_PRELOADS__) { const lc = load_cache; lc.fork = lc.promise.then((result) => { @@ -655,7 +658,7 @@ async function _preload_data(intent) { // resolve, bail rather than creating an orphan fork if (lc === load_cache && result.type === 'loaded') { try { - return svelte.fork(() => { + return fork(() => { root.$set(result.props); update(result.props.page); current_tree = result.props.tree; @@ -882,6 +885,7 @@ async function get_navigation_result_from_branch({ id: route?.id ?? null }, state: {}, + shallow: null, status: status ?? error?.status ?? 200, url: new URL(url), form: form ?? null, @@ -1662,6 +1666,9 @@ function _before_navigate({ url, type, intent, delta, event, scroll }) { if (!is_navigating) { // Don't run the event during redirects + // TODO this isn't fully right: if you do a goto(...) while another goto(...) is in progress, + // or you click a link while a navigation is in progress, the beforNavigate calls are not triggered, + // and maybe they should be? before_navigate_callbacks.forEach((fn) => fn(cancellable)); } @@ -1856,6 +1863,7 @@ async function navigate({ const entry = { [HISTORY_INDEX]: (current_history_index += change), [NAVIGATION_INDEX]: (current_navigation_index += change), + [STATES_PERSISTED_KEY]: true, [STATES_KEY]: state }; @@ -1879,6 +1887,7 @@ async function navigate({ } navigation_result.props.page.state = state; + navigation_result.props.page.shallow = null; /** * @type {Promise | undefined} @@ -1962,7 +1971,7 @@ async function navigate({ } update(navigation_result.props.page); - commit_promise = svelte.settled?.(); + commit_promise = settled(); } has_navigated = true; @@ -1972,7 +1981,7 @@ async function navigate({ const { activeElement } = document; - await (commit_promise ?? svelte.tick()); + await commit_promise; if (navigation_token !== nav_token) { // a new navigation happened while we were waiting for the DOM to update, so abort @@ -2261,7 +2270,7 @@ export async function handle_error(error, event) { * @param {T} callback */ function add_navigation_callback(callbacks, callback) { - svelte.onMount(() => { + onMount(() => { callbacks.add(callback); return () => { @@ -2282,7 +2291,7 @@ export function afterNavigate(callback) { } /** - * A navigation interceptor that triggers before we navigate to a URL, whether by clicking a link, calling `goto(...)`, or using the browser back/forward controls. + * A navigation interceptor that triggers before we navigate to a URL, whether by clicking a link, calling `goto(...)`, `pushState(...)` or `replaceState(...)` with a non-empty URL, or using the browser back/forward controls. * * Calling `cancel()` will prevent the navigation from completing. If `navigation.type === 'leave'` — meaning the user is navigating away from the app (or closing the tab) — calling `cancel` will trigger the native browser unload confirmation dialog. In this case, the navigation may or may not be cancelled depending on the user's response. * @@ -2560,63 +2569,57 @@ export async function preloadCode(pathname) { } /** - * Programmatically create a new history entry with the given `page.state`. To use the current URL, you can pass `''` as the first argument. Used for [shallow routing](https://svelte.dev/docs/kit/shallow-routing). + * Programmatically create a new history entry with the given `page.state`. To keep the current URL and shallow routing context, pass `''` as the first argument. Otherwise, this triggers the navigation lifecycle hooks with a `navigation.type` of `'shallow'`. Used for [shallow routing](https://svelte.dev/docs/kit/shallow-routing). * - * @param {string | URL} url + * Passing `null` as the first argument ends shallow routing and reverts the URL to the value of page.url. + * + * @param {string | URL | null} url * @param {App.PageState} state - * @returns {void} + * @param {Object} [options] + * @param {boolean} [options.persist] Whether to persist the state across a full page reload. Defaults to `false`. + * @returns {Promise} */ -export function pushState(url, state) { +export async function pushState(url, state, options = {}) { if (!BROWSER) { throw new Error('Cannot call pushState(...) on the server'); } - if (DEV) { - if (!started) { - throw new Error('Cannot call pushState(...) before router is initialized'); - } - - try { - // use `devalue.stringify` as a convenient way to ensure we exclude values that can't be properly rehydrated, such as custom class instances - devalue.stringify(state); - } catch (error) { - // @ts-expect-error - throw new Error(`Could not serialize state${error.path}`, { cause: error }); - } - } - - update_scroll_positions(current_history_index); - - const opts = { - [HISTORY_INDEX]: (current_history_index += 1), - [NAVIGATION_INDEX]: current_navigation_index, - [PAGE_URL_KEY]: page.url.href, - [STATES_KEY]: state - }; - - history.pushState(opts, '', resolve_url(url)); - has_navigated = true; - - page.state = state; - - clear_onward_history(current_history_index, current_navigation_index); + // Untrack to avoid triggering outer reactive contexts because we access page.X inside + await untrack(() => update_state(url, state, false, options.persist ?? false)); } /** - * Programmatically replace the current history entry with the given `page.state`. To use the current URL, you can pass `''` as the first argument. Used for [shallow routing](https://svelte.dev/docs/kit/shallow-routing). + * Programmatically replace the current history entry with the given `page.state`. To keep the current URL and shallow routing context, pass `''` as the first argument. Otherwise, this triggers the navigation lifecycle hooks with a `navigation.type` of `'shallow'`. Used for [shallow routing](https://svelte.dev/docs/kit/shallow-routing). * - * @param {string | URL} url + * Passing `null` as the first argument ends shallow routing and reverts the URL to the value of page.url. + * + * @param {string | URL | null} url * @param {App.PageState} state - * @returns {void} + * @param {Object} [options] + * @param {boolean} [options.persist] Whether to persist the state across a full page reload. Defaults to `false`. + * @returns {Promise} */ -export function replaceState(url, state) { +export async function replaceState(url, state, options = {}) { if (!BROWSER) { throw new Error('Cannot call replaceState(...) on the server'); } + // Untrack to avoid triggering outer reactive contexts because we access page.X inside + await untrack(() => update_state(url, state, true, options.persist ?? false)); +} + +/** + * @param {string | URL | null} url + * @param {App.PageState} state + * @param {boolean} replace + * @param {boolean} persist + */ +async function update_state(url, state, replace, persist) { if (DEV) { if (!started) { - throw new Error('Cannot call replaceState(...) before router is initialized'); + throw new Error( + `Cannot call ${replace ? 'replaceState' : 'pushState'}(...) before router is initialized` + ); } try { @@ -2628,16 +2631,97 @@ export function replaceState(url, state) { } } - const opts = { - [HISTORY_INDEX]: current_history_index, + const resolved = !url ? null : resolve_url(url); + const intent = resolved ? await get_navigation_intent(resolved, false) : undefined; + const nav = resolved ? _before_navigate({ url: resolved, type: 'shallow', intent }) : undefined; + + if (resolved && !nav) return; + + const nav_token = {}; + + if (nav) { + navigation_token = invalidation_token = nav_token; + is_navigating = true; + navigating.current = nav.navigation; + updating = true; + } + + if (!replace) update_scroll_positions(current_history_index); + + const entry = { + [HISTORY_INDEX]: (current_history_index += replace ? 0 : 1), [NAVIGATION_INDEX]: current_navigation_index, - [PAGE_URL_KEY]: page.url.href, + ...((resolved || (url === '' && page.shallow)) && { [PAGE_URL_KEY]: page.url.href }), + [STATES_PERSISTED_KEY]: persist, [STATES_KEY]: state }; - history.replaceState(opts, '', resolve_url(url)); + const fn = replace ? history.replaceState : history.pushState; + if (resolved) { + fn.call(history, entry, '', resolved); + } else { + fn.call(history, entry, '', url == null ? page.url.href : undefined); + } + + if (!replace) { + has_navigated = true; + clear_onward_history(current_history_index, current_navigation_index); + } + + if (nav) { + const after_navigate = ( + await Promise.all( + // eslint-disable-next-line @typescript-eslint/await-thenable -- we need to await because they can be asynchronous + Array.from(on_navigate_callbacks, (fn) => + fn(/** @type {import('@sveltejs/kit').OnNavigate} */ (nav.navigation)) + ) + ) + ).filter(/** @returns {value is () => void} */ (value) => typeof value === 'function'); + + if (after_navigate.length > 0) { + function cleanup() { + after_navigate.forEach((fn) => after_navigate_callbacks.delete(fn)); + } + + after_navigate.push(cleanup); + after_navigate.forEach((fn) => after_navigate_callbacks.add(fn)); + } + } page.state = state; + if (resolved) { + page.shallow = { + params: intent?.params ?? null, + route: intent ? { id: intent.route.id } : null, + url: resolved + }; + } else if (url === null) { + page.shallow = null; + } + + if (!nav) return; + + await settled(); + + if (navigation_token !== nav_token) { + // a new navigation happened while we were waiting for the DOM to update, so abort + nav.reject(new Error('navigation aborted')); + return; + } + + is_navigating = false; + nav.fulfil(undefined); + + if (nav.navigation.to) { + nav.navigation.to.scroll = scroll_state(); + } + + after_navigate_callbacks.forEach((fn) => + fn(/** @type {import('@sveltejs/kit').AfterNavigate} */ (nav.navigation)) + ); + + navigating.current = null; + updating = false; } /** @@ -2669,7 +2753,7 @@ export async function applyAction(result) { }); // ...so that setting the `form` prop takes effect and isn't ignored - await svelte.tick(); + await tick(); root.$set({ form: result.data }); if (result.type === 'success') { @@ -2704,7 +2788,7 @@ export async function set_nearest_error_page(error) { current_tree = navigation_result.props.tree; update(navigation_result.props.page); - void svelte.tick().then(() => reset_focus(current.url)); + void tick().then(() => reset_focus(current.url)); } } @@ -2951,6 +3035,16 @@ function _start_router() { page.state = state; } + const shallow_url = event.state[PAGE_URL_KEY] ? new URL(location.href) : null; + const intent = shallow_url ? await get_navigation_intent(shallow_url, false) : undefined; + page.shallow = shallow_url + ? { + params: intent?.params ?? null, + route: intent ? { id: intent.route.id } : null, + url: shallow_url + } + : null; + update_url(url); scroll_positions[current_history_index] = scroll_state(); @@ -3151,7 +3245,9 @@ async function _hydrate( if (!result) return; if (result.props.page) { - result.props.page.state = {}; + result.props.page.state = history.state?.[STATES_PERSISTED_KEY] + ? (history.state[STATES_KEY] ?? {}) + : {}; } await initialize(result, target, hydrate); @@ -3414,7 +3510,7 @@ function create_navigation(current, intent, url, type, target_scroll = null) { url, scroll: target_scroll }, - willUnload: !intent, + willUnload: type !== 'shallow' && !intent, type, complete }); diff --git a/packages/kit/src/runtime/client/constants.js b/packages/kit/src/runtime/client/constants.js index ef529228245e..dc8e87e2523a 100644 --- a/packages/kit/src/runtime/client/constants.js +++ b/packages/kit/src/runtime/client/constants.js @@ -1,6 +1,7 @@ export const SNAPSHOT_KEY = 'sveltekit:snapshot'; export const SCROLL_KEY = 'sveltekit:scroll'; export const STATES_KEY = 'sveltekit:states'; +export const STATES_PERSISTED_KEY = 'sveltekit:states-persisted'; export const PAGE_URL_KEY = 'sveltekit:pageurl'; export const HISTORY_INDEX = 'sveltekit:history'; diff --git a/packages/kit/src/runtime/client/state.svelte.js b/packages/kit/src/runtime/client/state.svelte.js index f29bc9d3728c..8fc8de0a8de4 100644 --- a/packages/kit/src/runtime/client/state.svelte.js +++ b/packages/kit/src/runtime/client/state.svelte.js @@ -9,6 +9,7 @@ export const page = new (class Page { error = $state.raw(null); params = $state.raw({}); route = $state.raw({ id: null }); + shallow = $state.raw(null); state = $state.raw({}); status = $state.raw(-1); url = $state.raw(new URL('a:')); diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index ef411446d0c0..58d49f75f5d4 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -153,6 +153,7 @@ export async function render_response({ url: event.url, data: {}, form: form_value, + shallow: null, state: {} } }; diff --git a/packages/kit/src/types/ambient.d.ts b/packages/kit/src/types/ambient.d.ts index d01f3b4a426d..94e650a288ea 100644 --- a/packages/kit/src/types/ambient.d.ts +++ b/packages/kit/src/types/ambient.d.ts @@ -44,7 +44,7 @@ declare namespace App { export interface PageData {} /** - * The shape of the `page.state` object, which can be manipulated using the [`pushState`](https://svelte.dev/docs/kit/$app-navigation#pushState) and [`replaceState`](https://svelte.dev/docs/kit/$app-navigation#replaceState) functions from `$app/navigation`. + * The shape of the `page.state` object, which can be manipulated using [`goto`](https://svelte.dev/docs/kit/$app-navigation#goto), [`pushState`](https://svelte.dev/docs/kit/$app-navigation#pushState) and [`replaceState`](https://svelte.dev/docs/kit/$app-navigation#replaceState). */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface PageState {} diff --git a/packages/kit/test/apps/basics/src/app.d.ts b/packages/kit/test/apps/basics/src/app.d.ts index e3198c7bd1c7..8e3063590293 100644 --- a/packages/kit/test/apps/basics/src/app.d.ts +++ b/packages/kit/test/apps/basics/src/app.d.ts @@ -14,6 +14,17 @@ declare global { count?: number; } } + + interface Window { + shallow_navigation_log: Array<{ + hook: string; + params?: Record | null; + path?: string; + route?: string | null; + state?: string | null; + type?: string; + }>; + } } export {}; diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte index 28de28c4ab08..7ff9e395d65a 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte @@ -1,3 +1,48 @@ + + push-state a b diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte index 07c88d358be5..46e15ef155ad 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte @@ -1,15 +1,22 @@ @@ -17,7 +24,31 @@ + + + + + + +

active: {page.state.active ?? false}

-{data.now} + + {page.shallow + ? `${page.shallow.url.pathname} ${page.shallow.route?.id ?? 'null'} ${JSON.stringify(page.shallow.params)}` + : 'null'} + +{resolved} +{data.now} diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/[param]/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/[param]/+page.svelte new file mode 100644 index 000000000000..f4630b6bb5b0 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/[param]/+page.svelte @@ -0,0 +1 @@ +

parameterized child

diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte index 4a184133f3ea..330c86719cea 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte @@ -5,3 +5,4 @@

a

active: {page.state.active ?? false}

+{page.shallow ? page.shallow.url.pathname : 'null'} diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+layout.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+layout.svelte index 7e6089f7721f..a86a603bb52c 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+layout.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+layout.svelte @@ -1,3 +1,30 @@ + + replace-state a b diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte index a27536df1a55..2bd176f95e6b 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte @@ -2,12 +2,12 @@ import { replaceState } from '$app/navigation'; import { page } from '$app/state'; - function one() { - replaceState('', { active: true }); + async function one() { + await replaceState('', { active: true }); } - function two() { - replaceState('/shallow-routing/replace-state/a', { active: true }); + async function two() { + await replaceState('/shallow-routing/replace-state/a', { active: true }); } @@ -15,5 +15,12 @@ + +

active: {page.state.active ?? false}

+{page.shallow ? page.shallow.url.pathname : 'null'} diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/effect/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/effect/+page.svelte index cf298aac8150..dda44dd05f2c 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/effect/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/effect/+page.svelte @@ -1,5 +1,5 @@ @@ -31,25 +31,35 @@ The modal can be dismissed by navigating back (unsetting `page.state.showModal`) ## API -The first argument to `pushState` is the URL, relative to the current URL. A non-empty URL performs a shallow navigation: [`beforeNavigate`]($app-navigation#beforeNavigate), [`onNavigate`]($app-navigation#onNavigate), [`onNavigate`]($app-navigation#onNavigate), [`onNavigate`]($app-navigation#onNavigate) and [`afterNavigate`]($app-navigation#afterNavigate) will run with `navigation.type === 'shallow'`. +To perform a shallow navigation, call `goto` with the `shallow` option: -Once shallow routing is active, `page.shallow` is set with the URL, parameters and route id that is user-visible. `page.url`, `page.params` and `page.route` remain as they were before the shallow navigation. +```js +await goto('/photos/1', { + shallow: true, + state: { showModal: true } +}); +``` + +This updates the visible URL and `page.state` without running `load` functions or changing the rendered page. [`beforeNavigate`]($app-navigation#beforeNavigate), [`onNavigate`]($app-navigation#onNavigate) and [`afterNavigate`]($app-navigation#afterNavigate) will run with `navigation.type === 'goto'` and `navigation.shallow === true`. + +Once shallow routing is active, `page.shallow` is set with the visible URL, parameters and route id. `page.url`, `page.params` and `page.route` continue to describe the page that was last rendered as a result of an actual navigation. A regular `goto` call without `shallow: true`, or a link click, exits shallow routing. -To keep the current URL, use `''`. This only updates history and page state, and does not call navigation lifecycle functions. If the page is already showing a shallow route, `page.shallow` is preserved; otherwise it remains `null`. +To create a history entry with new page state without changing the URL, pass `null` instead: -Passing `null` as the first argument will exit shallow routing (as do `goto` or link clicks). This reverts the visible URL to what it was before and sets `page.shallow` to `null`. +```js +await goto(null, { state: { showModal: true } }); +``` -The second argument is the new page state, which can be accessed via the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). +This does not call navigation lifecycle functions. If the page is already showing a shallow route, `page.shallow` and the visible URL are preserved. Otherwise, `page.shallow` remains `null`. -After a shallow navigation, `page.shallow` contains the target `url`, `params` and `route`. The actual page has not changed, so `page.url`, `page.params` and `page.route` continue to describe the page that is currently rendered. +In both cases, `state` can be accessed through the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). -`pushState` and `replaceState` return promises that resolve after the page state change has been applied to the DOM. To set page state without creating a new history entry, use `replaceState` instead of `pushState`. +By default, `goto` creates a new history entry. To replace the current entry, pass `replace: true`. -By default, state set through `pushState` or `replaceState` is discarded on a full page reload. Pass `{ persist: true }` as the third argument to restore it in the browser after reloading, independently of whether the call performs a shallow navigation: +By default, state set through `goto` is not restored in the browser after a reload. To preserve it across full page reloads, use `goto(..., { state, persistState: true })`. -```js -await pushState('', { showModal: true }, { persist: true }); -``` +> [!NOTE] +> `pushState` and `replaceState` are deprecated. Use `goto` with `shallow: true` or a `null` URL instead, and use the `replace` option when replacing the current history entry. ## Loading data for a route @@ -60,7 +70,7 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c ```svelte +

navigation test finish

+

active: {page.state.active ?? false}

diff --git a/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/on-navigate/[x]/+page.svelte b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/on-navigate/[x]/+page.svelte index 72b5dd0a36e5..105d41c4b27f 100644 --- a/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/on-navigate/[x]/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/on-navigate/[x]/+page.svelte @@ -9,6 +9,7 @@ /** @type {Omit} */ let type; + let shallow = false; let called_return = false; @@ -16,6 +17,7 @@ from = navigation.from; to = navigation.to; type = navigation.type; + shallow = navigation.shallow; }); onNavigate(() => { @@ -25,5 +27,7 @@ }); -

{`${from?.url.pathname} -> ${to?.url.pathname}`} ({type ?? '...'}) {called_return}

+

+ {`${from?.url.pathname} -> ${to?.url.pathname} (${type ?? '...'}) ${shallow} ${called_return}`} +

/b diff --git a/packages/kit/test/apps/basics/src/routes/scroll/push-state/+page.svelte b/packages/kit/test/apps/basics/src/routes/scroll/push-state/+page.svelte index d423875457a3..a9224aabf8dd 100644 --- a/packages/kit/test/apps/basics/src/routes/scroll/push-state/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/scroll/push-state/+page.svelte @@ -1,9 +1,9 @@ diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte index 7ff9e395d65a..625c29c41956 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte @@ -7,13 +7,14 @@ }); beforeNavigate((navigation) => { - if (navigation.type !== 'shallow') return; + if (!navigation.shallow) return; window.shallow_navigation_log.push({ hook: 'before', params: navigation.to?.params, path: navigation.to?.url.pathname, route: navigation.to?.route.id, + shallow: navigation.shallow, type: navigation.type }); @@ -27,15 +28,20 @@ }); onNavigate((navigation) => { - if (navigation.type === 'shallow') { - window.shallow_navigation_log.push({ hook: 'on', type: navigation.type }); + if (navigation.shallow) { + window.shallow_navigation_log.push({ + hook: 'on', + shallow: navigation.shallow, + type: navigation.type + }); } }); afterNavigate((navigation) => { - if (navigation.type === 'shallow') { + if (navigation.shallow) { window.shallow_navigation_log.push({ hook: 'after', + shallow: navigation.shallow, state: document.querySelector('p')?.textContent ?? null, type: navigation.type }); diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte index 46e15ef155ad..8f86cf6e6538 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte @@ -1,5 +1,5 @@

parent

- - - - - - + + + + + goto(null, { state: { active: true }, persistState: true })} + >persist state only + goto('/shallow-routing/push-state/a', { + shallow: true, + state: { active: true }, + persistState: true + })}>persist shallow state + +

active: {page.state.active ?? false}

diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/refresh/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/refresh/+page.svelte index ea3961aba72e..9a733223ed22 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/refresh/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/refresh/+page.svelte @@ -1,17 +1,17 @@

refresh

- + { - if (navigation.type === 'shallow') { - window.shallow_navigation_log.push({ hook: 'before', type: navigation.type }); + if (navigation.shallow) { + window.shallow_navigation_log.push({ + hook: 'before', + shallow: navigation.shallow, + type: navigation.type + }); } }); onNavigate((navigation) => { - if (navigation.type === 'shallow') { - window.shallow_navigation_log.push({ hook: 'on', type: navigation.type }); + if (navigation.shallow) { + window.shallow_navigation_log.push({ + hook: 'on', + shallow: navigation.shallow, + type: navigation.type + }); } }); afterNavigate((navigation) => { - if (navigation.type === 'shallow') { - window.shallow_navigation_log.push({ hook: 'after', type: navigation.type }); + if (navigation.shallow) { + window.shallow_navigation_log.push({ + hook: 'after', + shallow: navigation.shallow, + type: navigation.type + }); } }); diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte index 2bd176f95e6b..224b44479638 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte @@ -1,25 +1,33 @@

parent

- - + - goto(null, { replace: true, state: { active: true }, persistState: true })} + >persist state only

active: {page.state.active ?? false}

diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index f669bdd6bb89..61ab4c02d54f 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1500,6 +1500,38 @@ test.describe('goto', () => { ); }); + test('supports the deprecated replaceState option', async ({ app, page }) => { + /** @type {string[]} */ + const warnings = []; + page.on('console', (message) => { + if (message.type() === 'warning') warnings.push(message.text()); + }); + + await page.goto('/goto/testentry'); + await app.goto('/goto/teststart'); + await app.goto('/goto/testfinish', { replaceState: true }); + await page.goBack(); + await expect(page).toHaveURL('/goto/testentry'); + + expect(warnings.filter((warning) => warning.includes('replaceState'))).toEqual( + process.env.DEV + ? [ + 'The `goto(..., { replaceState: true })` option has been deprecated in favour of `replace`' + ] + : [] + ); + }); + + test('persists state through redirects when persistState is true', async ({ app, page }) => { + await page.goto('/goto/teststart'); + await app.goto('/goto/loadreplace1', { state: { active: true }, persistState: true }); + await expect(page).toHaveURL('/goto/testfinish'); + await expect(page.locator('p')).toHaveText('active: true'); + + await page.reload(); + await expect(page.locator('p')).toHaveText('active: true'); + }); + test.describe('navigation and redirects should be consistent between web native and sveltekit based', () => { const testEntryPage = '/goto/testentry'; const testStartPage = '/goto/teststart'; @@ -1534,7 +1566,7 @@ test.describe('goto', () => { test('app.goto rejects and does not navigate', async ({ app, page }) => { // `goto` is only for routes within the app; navigating to a // non-existent route rejects and leaves the URL unchanged - await expect(app.goto(nonexistentPage, { replaceState: false })).rejects.toBeTruthy(); + await expect(app.goto(nonexistentPage, { replace: false })).rejects.toBeTruthy(); await expect(page).toHaveURL(testStartPage); }); @@ -1552,7 +1584,7 @@ test.describe('goto', () => { test('app.goto rejects and does not navigate', async ({ app, page }) => { // `goto` is only for routes within the app; navigating to a // non-existent route rejects and leaves the URL unchanged - await expect(app.goto(nonexistentPage, { replaceState: true })).rejects.toBeTruthy(); + await expect(app.goto(nonexistentPage, { replace: true })).rejects.toBeTruthy(); await expect(page).toHaveURL(testStartPage); }); @@ -1567,7 +1599,7 @@ test.describe('goto', () => { test.describe('redirect after invalidation', () => { test.beforeEach(async ({ app }) => { - await app.goto(`${testStartPage}?redirect`, { replaceState: true }); + await app.goto(`${testStartPage}?redirect`, { replace: true }); }); const expectGoback = makeExpectGoback(testFinishPage, testEntryPage); @@ -1590,7 +1622,7 @@ test.describe('goto', () => { const expectGoback = makeExpectGoback(testFinishPage, testStartPage); test('app.goto', async ({ app, page }) => { - await app.goto(loadReplacePage, { replaceState: false }); + await app.goto(loadReplacePage, { replace: false }); await expectGoback(page); }); @@ -1608,7 +1640,7 @@ test.describe('goto', () => { const expectGoback = makeExpectGoback(testFinishPage, testEntryPage); test('app.goto', async ({ app, page }) => { - await app.goto(loadReplacePage, { replaceState: true }); + await app.goto(loadReplacePage, { replace: true }); await expectGoback(page); }); @@ -1646,7 +1678,7 @@ test.describe('untrack', () => { }); test.describe('Shallow routing', () => { - test('Pushes state to the current URL', async ({ page }) => { + test('Adds state without changing the current URL', async ({ page }) => { await page.goto('/shallow-routing/push-state'); await expect(page.locator('p')).toHaveText('active: false'); @@ -1660,7 +1692,7 @@ test.describe('Shallow routing', () => { await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); }); - test('Pushes state to a new URL', async ({ baseURL, page }) => { + test('Shallow navigates to a new URL', async ({ baseURL, page }) => { await page.goto('/shallow-routing/push-state'); await expect(page.locator('p')).toHaveText('active: false'); @@ -1712,10 +1744,11 @@ test.describe('Shallow routing', () => { params: { param: 'hello' }, path: '/shallow-routing/push-state/hello', route: '/shallow-routing/push-state/[param]', - type: 'shallow' + shallow: true, + type: 'goto' }, - { hook: 'on', type: 'shallow' }, - { hook: 'after', state: 'active: true', type: 'shallow' }, + { hook: 'on', shallow: true, type: 'goto' }, + { hook: 'after', shallow: true, state: 'active: true', type: 'goto' }, { hook: 'complete' } ]); }); @@ -1733,12 +1766,13 @@ test.describe('Shallow routing', () => { params: {}, path: '/shallow-routing/push-state', route: '/shallow-routing/push-state', - type: 'shallow' + shallow: true, + type: 'goto' } ]); }); - test('Pushes state without a shallow navigation and does not restore it by default', async ({ + test('Adds state without a shallow navigation and does not restore it by default', async ({ baseURL, page }) => { @@ -1755,17 +1789,16 @@ test.describe('Shallow routing', () => { await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); }); - test('Persists state without a shallow navigation when requested', async ({ page }) => { + test('Restores state-only updates when persistState is true', async ({ page }) => { await page.goto('/shallow-routing/push-state'); await page.locator('[data-id="state-only-persist"]').click(); await expect(page.locator('p')).toHaveText('active: true'); - await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); await page.reload(); await expect(page.locator('p')).toHaveText('active: true'); }); - test('Persists state from a shallow navigation when requested', async ({ page }) => { + test('Restores shallow state when persistState is true', async ({ page }) => { await page.goto('/shallow-routing/push-state'); await page.locator('[data-id="shallow-persist"]').click(); await expect(page.locator('p')).toHaveText('active: true'); @@ -1773,10 +1806,27 @@ test.describe('Shallow routing', () => { await page.reload(); await expect(page.locator('h1')).toHaveText('a'); await expect(page.locator('p')).toHaveText('active: true'); - await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); }); - test('Keeps the current shallow routing context when pushing state', async ({ page }) => { + test('Does not restore regular goto state by default', async ({ page }) => { + await page.goto('/shallow-routing/push-state'); + await page.locator('[data-id="goto-state"]').click(); + await expect(page.locator('p')).toHaveText('active: true'); + + await page.reload(); + await expect(page.locator('p')).toHaveText('active: false'); + }); + + test('Restores regular goto state when persistState is true', async ({ page }) => { + await page.goto('/shallow-routing/push-state'); + await page.locator('[data-id="goto-persist"]').click(); + await expect(page.locator('p')).toHaveText('active: true'); + + await page.reload(); + await expect(page.locator('p')).toHaveText('active: true'); + }); + + test('Keeps the current shallow routing context when adding state', async ({ page }) => { await page.goto('/shallow-routing/push-state'); await page.locator('[data-id="two"]').click(); await expect(page.locator('[data-id="shallow"]')).toHaveText( @@ -1797,9 +1847,7 @@ test.describe('Shallow routing', () => { ); }); - test('Ends the current shallow routing context when pushing state with null', async ({ - page - }) => { + test('Ends the current shallow routing context with a regular goto', async ({ page }) => { await page.goto('/shallow-routing/push-state'); await page.locator('[data-id="two"]').click(); await expect(page.locator('[data-id="shallow"]')).toHaveText( @@ -1816,23 +1864,25 @@ test.describe('Shallow routing', () => { await expect(page.locator('[data-id="shallow"]')).toHaveText( '/shallow-routing/push-state/a /shallow-routing/push-state/a {}' ); + await expect.poll(() => page.evaluate(() => window.shallow_navigation_log.length)).toBe(4); + expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([ + { + hook: 'before', + params: {}, + path: '/shallow-routing/push-state/a', + route: '/shallow-routing/push-state/a', + shallow: true, + type: 'popstate' + }, + { hook: 'on', shallow: true, type: 'popstate' }, + { hook: 'after', shallow: true, state: 'active: true', type: 'popstate' }, + { hook: 'complete' } + ]); await page.goForward(); await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); }); - test('Restores state set through goto on reload', async ({ page }) => { - await page.goto('/shallow-routing/push-state'); - await page.locator('[data-id="goto-state"]').click(); - await expect(page.locator('p')).toHaveText('active: true'); - - await page.reload(); - await expect(page.locator('p')).toHaveText('active: true'); - }); - - test('Invalidates the correct route after pushing state to a new URL', async ({ - baseURL, - page - }) => { + test('Invalidates the rendered route after a shallow navigation', async ({ baseURL, page }) => { await page.goto('/shallow-routing/push-state'); await expect(page.locator('p')).toHaveText('active: false'); @@ -1877,7 +1927,11 @@ test.describe('Shallow routing', () => { await expect(page.locator('p')).toHaveText('active: true'); }); - test('Replaces state on a new URL', async ({ baseURL, page, clicknav }) => { + test('Shallow navigates and replaces the current history entry', async ({ + baseURL, + page, + clicknav + }) => { await page.goto('/shallow-routing/replace-state/b'); await clicknav('[href="/shallow-routing/replace-state"]'); @@ -1885,9 +1939,9 @@ test.describe('Shallow routing', () => { await expect(page.locator('p')).toHaveText('active: true'); await expect.poll(() => page.evaluate(() => window.shallow_navigation_log.length)).toBe(3); expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([ - { hook: 'before', type: 'shallow' }, - { hook: 'on', type: 'shallow' }, - { hook: 'after', type: 'shallow' } + { hook: 'before', shallow: true, type: 'goto' }, + { hook: 'on', shallow: true, type: 'goto' }, + { hook: 'after', shallow: true, type: 'goto' } ]); await page.evaluate(() => (window.shallow_navigation_log = [])); @@ -1907,7 +1961,7 @@ test.describe('Shallow routing', () => { await expect(page.locator('p')).toHaveText('active: true'); }); - test('Persists replaced state when requested', async ({ baseURL, page }) => { + test('Restores replaced state on reload', async ({ baseURL, page }) => { await page.goto('/shallow-routing/replace-state'); await page.locator('[data-id="state-only"]').click(); @@ -1920,9 +1974,7 @@ test.describe('Shallow routing', () => { await expect(page.locator('p')).toHaveText('active: true'); }); - test('Ends the current shallow routing context when replacing state with null', async ({ - page - }) => { + test('Ends a replaced shallow routing context with a regular goto', async ({ page }) => { await page.goto('/shallow-routing/replace-state'); await page.locator('[data-id="two"]').click(); await expect(page.locator('[data-id="shallow"]')).toHaveText( @@ -1936,18 +1988,46 @@ test.describe('Shallow routing', () => { expect(new URL(page.url()).pathname).toBe('/shallow-routing/replace-state'); }); - test('pushState does not loop infinitely in $effect', async ({ page }) => { + test('pushState remains functional and does not loop infinitely in $effect', async ({ page }) => { + /** @type {string[]} */ + const warnings = []; + page.on('console', (message) => { + if (message.type() === 'warning') warnings.push(message.text()); + }); + await page.goto('/shallow-routing/push-state/effect'); await expect(page.locator('p')).toHaveText('count: 0'); await page.locator('button').click(); await expect(page.locator('p')).toHaveText('count: 1'); + expect(warnings.filter((warning) => warning.includes('pushState(...)'))).toEqual( + process.env.DEV + ? [ + '`pushState(...)` is deprecated. Use `goto(url, { state, shallow: true })` instead. To keep the current URL, use `goto(null, { state })`.' + ] + : [] + ); }); - test('replaceState does not loop infinitely in $effect', async ({ page }) => { + test('replaceState remains functional and does not loop infinitely in $effect', async ({ + page + }) => { + /** @type {string[]} */ + const warnings = []; + page.on('console', (message) => { + if (message.type() === 'warning') warnings.push(message.text()); + }); + await page.goto('/shallow-routing/replace-state/effect'); await expect(page.locator('p')).toHaveText('count: 0'); await page.locator('button').click(); await expect(page.locator('p')).toHaveText('count: 1'); + expect(warnings.filter((warning) => warning.includes('replaceState(...)'))).toEqual( + process.env.DEV + ? [ + '`replaceState(...)` is deprecated. Use `goto(url, { state, shallow: true, replace: true })` instead. To keep the current URL, use `goto(null, { state, replace: true })`.' + ] + : [] + ); }); test('refreshAll reruns load functions without resetting page.state', async ({ page }) => { diff --git a/packages/kit/test/apps/basics/test/cross-platform/client.test.js b/packages/kit/test/apps/basics/test/cross-platform/client.test.js index fdd42caf27cb..dd6c740c0306 100644 --- a/packages/kit/test/apps/basics/test/cross-platform/client.test.js +++ b/packages/kit/test/apps/basics/test/cross-platform/client.test.js @@ -284,11 +284,11 @@ test.describe('Navigation lifecycle functions', () => { test('onNavigate calls callback', async ({ page, clicknav }) => { await page.goto('/navigation-lifecycle/on-navigate/a'); - expect(await page.textContent('h1')).toBe('undefined -> undefined (...) false'); + expect(await page.textContent('h1')).toBe('undefined -> undefined (...) false false'); await clicknav('[href="/navigation-lifecycle/on-navigate/b"]'); expect(await page.textContent('h1')).toBe( - '/navigation-lifecycle/on-navigate/a -> /navigation-lifecycle/on-navigate/b (link) true' + '/navigation-lifecycle/on-navigate/a -> /navigation-lifecycle/on-navigate/b (link) false true' ); }); diff --git a/packages/kit/test/apps/hash-based-routing/src/routes/+layout.svelte b/packages/kit/test/apps/hash-based-routing/src/routes/+layout.svelte index 76cae24dfaf8..4bdf686b0666 100644 --- a/packages/kit/test/apps/hash-based-routing/src/routes/+layout.svelte +++ b/packages/kit/test/apps/hash-based-routing/src/routes/+layout.svelte @@ -1,5 +1,5 @@ + + {#if page.state.showModal} history.back()} /> {/if} @@ -29,12 +31,18 @@ SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, The modal can be dismissed by navigating back (unsetting `page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically. -## API +The above sets state and creates a new navigation entry. If you don't want that, use `replace: true` (i.e. `goto(null, { state, replace: true })`). Doing so replaces the current history entry and no new entry is created. + +`state` can be accessed through the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). + +By default, state set through `goto` is not restored in the browser after a reload. To preserve it across full page reloads, use `goto(..., { state, persistState: true })`. Keep in mind that `page.state` is only populated after JavaScript is loaded, so there could be flickers from changing UI elements if you use this option. + +## Shallow routing -To perform a shallow navigation, call `goto` with the `shallow` option: +The above writes to the history state but not the URL. You can also write to the URL without performing a real navigation - a so-called shallow navigation - by using `goto` with the `shallow` option: ```js -await goto('/photos/1', { +goto('/photos/1', { shallow: true, state: { showModal: true } }); @@ -42,24 +50,28 @@ await goto('/photos/1', { This updates the visible URL and `page.state` without running `load` functions or changing the rendered page. [`beforeNavigate`]($app-navigation#beforeNavigate), [`onNavigate`]($app-navigation#onNavigate) and [`afterNavigate`]($app-navigation#afterNavigate) will run with `navigation.type === 'goto'` and `navigation.shallow === true`. -Once shallow routing is active, `page.shallow` is set with the visible URL, parameters and route id. `page.url`, `page.params` and `page.route` continue to describe the page that was last rendered as a result of an actual navigation. A regular `goto` call without `shallow: true`, or a link click, exits shallow routing. +Once shallow routing is active, `page.shallow` is set with the visible URL, parameters and route id. `page.url`, `page.params` and `page.route` continue to describe the page that was last rendered as a result of an actual navigation. -To create a history entry with new page state without changing the URL, pass `null` instead: - -```js -await goto(null, { state: { showModal: true } }); -``` +```svelte + + -This does not call navigation lifecycle functions. If the page is already showing a shallow route, `page.shallow` and the visible URL are preserved. Otherwise, `page.shallow` remains `null`. +

The user-visible URL is {page.shallow?.url.href ?? page.url.href}

+

The actual page you're on is {page.url.href}

-In both cases, `state` can be accessed through the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). + +``` -By default, `goto` creates a new history entry. To replace the current entry, pass `replace: true`. +A regular `goto` call without `shallow: true`, or a link click, exits shallow routing. -By default, state set through `goto` is not restored in the browser after a reload. To preserve it across full page reloads, use `goto(..., { state, persistState: true })`. +> [!NOTE] +> `goto(null, { state: ... });` does _not_ trigger the navigation hooks, and does not change in what mode the navigation is: If the page is already showing a shallow route, `page.shallow` and the visible URL are preserved. Otherwise, `page.shallow` remains `null`. > [!NOTE] -> `pushState` and `replaceState` are deprecated. Use `goto` with `shallow: true` or a `null` URL instead, and use the `replace` option when replacing the current history entry. +> In SvelteKit 2 this functionality was achieved using `pushState` and `replaceState`, which are now deprecated. Use `goto` with `shallow: true` instead, and use the `replace` option when replacing the current history entry. ## Loading data for a route @@ -123,3 +135,5 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c During server-side rendering, `page.state` is always an empty object. Shallow routing is a feature that requires JavaScript to work. Be mindful when using it and try to think of sensible fallback behavior in case JavaScript isn't available. + +If you navigate to another page via shallow routing, reloading on that route will not start the app in shallow routing mode. Instead the actual page on that URL is loaded. On the server this is unavoidable (because history state isn't available at that point), and hence it would mean too much of a UI flicker if it would change the page and enter shallow routing once JavaScript is loaded. From b5eaf965385d388d7749fc77ed968f3477759f11 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 22 Jul 2026 00:17:43 +0200 Subject: [PATCH 07/29] fix #16457 / fix #15618 --- packages/kit/src/runtime/client/client.js | 10 +++++++-- .../shallow-routing/push-state/a/+page.svelte | 10 +++++++++ .../kit/test/apps/basics/test/client.test.js | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index d6fb659c0b55..250743a2ada1 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -3079,9 +3079,15 @@ function _start_router() { const state = event.state[STATES_KEY] ?? {}; const url = new URL(event.state[PAGE_URL_KEY] ?? location.href); const navigation_index = event.state[NAVIGATION_INDEX]; - const is_hash_change = current.url ? strip_hash(location) === strip_hash(current.url) : false; + const is_hash_change = + current.url && (location.href + current.url.href).includes('#') // check if even has a hash + ? strip_hash(location) === strip_hash(current.url) + : false; const shallow = - navigation_index === current_navigation_index && (has_navigated || is_hash_change); + navigation_index === current_navigation_index && + ((has_navigated && + (!event.state?.[PAGE_URL_KEY] || event.state[PAGE_URL_KEY] === location.href)) || + is_hash_change); const shallow_url = event.state[PAGE_URL_KEY] ? new URL(location.href) : null; const shallow_intent = shallow_url ? await get_navigation_intent(shallow_url, false) diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte index 330c86719cea..b79c74dcecc1 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte @@ -1,8 +1,18 @@

a

active: {page.state.active ?? false}

{page.shallow ? page.shallow.url.pathname : 'null'} + + diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 61ab4c02d54f..c04f0c45c6d4 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1704,6 +1704,7 @@ test.describe('Shallow routing', () => { '/shallow-routing/push-state/a /shallow-routing/push-state/a {}' ); + // Reload should mean we don't do shallow routing and instead show the real page await page.reload(); await expect(page.locator('h1')).toHaveText('a'); await expect(page.locator('p')).toHaveText('active: false'); @@ -1714,6 +1715,7 @@ test.describe('Shallow routing', () => { await expect(page.locator('h1')).toHaveText('parent'); await expect(page.locator('p')).toHaveText('active: false'); + // Going forward again should reactivate shallow routing await page.goForward(); expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state/a`); await expect(page.locator('h1')).toHaveText('parent'); @@ -1721,6 +1723,25 @@ test.describe('Shallow routing', () => { await expect(page.locator('[data-id="shallow"]')).toHaveText( '/shallow-routing/push-state/a /shallow-routing/push-state/a {}' ); + + await page.reload(); + await expect(page.locator('h1')).toHaveText('a'); + await expect(page.locator('p')).toHaveText('active: false'); + await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); + + await page.locator('[data-id="shallow-b"]').click(); + expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state/b`); + await expect(page.locator('h1')).toHaveText('a'); + await expect(page.locator('p')).toHaveText('active: true'); + + // After reload + pushState + back, we should reactivate shallow routing + await page.goBack(); + expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state/a`); + await expect(page.locator('h1')).toHaveText('parent'); + await expect(page.locator('p')).toHaveText('active: true'); + await expect(page.locator('[data-id="shallow"]')).toHaveText( + '/shallow-routing/push-state/a /shallow-routing/push-state/a {}' + ); }); test('Exposes the shallow target and runs navigation lifecycle hooks', async ({ From 0e9d3a850f74226d51e93a1b5e9ce19b11f8d8db Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 22 Jul 2026 23:33:42 +0200 Subject: [PATCH 08/29] remove `goto(null, ...)` --- .../docs/30-advanced/67-shallow-routing.md | 43 ++++++++---- packages/kit/src/exports/public.d.ts | 32 +++++++-- packages/kit/src/runtime/client/client.js | 39 ++++------- packages/kit/test/ambient.d.ts | 14 +--- .../src/routes/scroll/push-state/+page.svelte | 2 +- .../shallow-routing/push-state/+page.svelte | 6 +- .../shallow-routing/refresh/+page.svelte | 2 +- .../replace-state/+page.svelte | 5 +- .../kit/test/apps/basics/test/client.test.js | 66 +++++++++++++++---- packages/kit/test/types.d.ts | 13 +--- 10 files changed, 134 insertions(+), 88 deletions(-) diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index a798f5337063..2143e0ad37ed 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -1,5 +1,5 @@ --- -title: History state & shallow routing +title: Page state & shallow routing --- As you navigate around a SvelteKit app, you create _history entries_. Clicking the back and forward buttons traverses through this list of entries, re-running any `load` functions and replacing page components as necessary. @@ -16,8 +16,9 @@ SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, import Modal from './Modal.svelte'; function showModal() { - goto(null, { - state: { showModal: true } + goto('', { + state: { showModal: true }, + shallow: true }); } @@ -29,17 +30,15 @@ SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, {/if} ``` -The modal can be dismissed by navigating back (unsetting `page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically. - -The above sets state and creates a new navigation entry. If you don't want that, use `replace: true` (i.e. `goto(null, { state, replace: true })`). Doing so replaces the current history entry and no new entry is created. +State can be accessed through the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). -`state` can be accessed through the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). +The modal can be dismissed by navigating back (unsetting `page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically. -By default, state set through `goto` is not restored in the browser after a reload. To preserve it across full page reloads, use `goto(..., { state, persistState: true })`. Keep in mind that `page.state` is only populated after JavaScript is loaded, so there could be flickers from changing UI elements if you use this option. +Because there was no real navigation but the history stack still updated, we call this shallow routing. ## Shallow routing -The above writes to the history state but not the URL. You can also write to the URL without performing a real navigation - a so-called shallow navigation - by using `goto` with the `shallow` option: +The above writes to the history state but not the URL. You can also write to the URL without performing a real navigation: ```js goto('/photos/1', { @@ -68,11 +67,33 @@ Once shallow routing is active, `page.shallow` is set with the visible URL, para A regular `goto` call without `shallow: true`, or a link click, exits shallow routing. > [!NOTE] -> `goto(null, { state: ... });` does _not_ trigger the navigation hooks, and does not change in what mode the navigation is: If the page is already showing a shallow route, `page.shallow` and the visible URL are preserved. Otherwise, `page.shallow` remains `null`. +> `goto('', { shallow: true, state: ... });` does _not_ trigger navigation hooks. > [!NOTE] > In SvelteKit 2 this functionality was achieved using `pushState` and `replaceState`, which are now deprecated. Use `goto` with `shallow: true` instead, and use the `replace` option when replacing the current history entry. +## Routing options + +The above examples set state and create a new navigation entry. If you don't want that, you can replace the existing navigation entry instead: + +```js +goto(url, { + state, + replace: true +}); +``` + +By default, state set with `goto` is not restored after a reload. To change that, use `persistState`: + +```js +goto(url, { + state, + persistState: true +}); +``` + +> [!NOTE] `page.state` is only populated after JavaScript loads, which can cause flickering UI. Use it carefully. + ## Loading data for a route When shallow routing, you may want to render another `+page.svelte` inside the current page. For example, clicking on a photo thumbnail could pop up the detail view without navigating to the photo page. @@ -136,4 +157,4 @@ During server-side rendering, `page.state` is always an empty object. Shallow routing is a feature that requires JavaScript to work. Be mindful when using it and try to think of sensible fallback behavior in case JavaScript isn't available. -If you navigate to another page via shallow routing, reloading on that route will not start the app in shallow routing mode. Instead the actual page on that URL is loaded. On the server this is unavoidable (because history state isn't available at that point), and hence it would mean too much of a UI flicker if it would change the page and enter shallow routing once JavaScript is loaded. +If you navigate to another page via shallow routing, reloading on that route will not start the app in shallow routing mode. Instead the actual page on that URL is loaded. On the server this is unavoidable (because history state isn't available at that point), and hence it would mean too much of a UI flicker if it would change the page and enter shallow routing once JavaScript is loaded. This is irregardless of the `persistState` option. diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 3a7d9af375ac..534650f7c2e5 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1264,17 +1264,32 @@ export interface NavigationTarget< } export interface GotoOptions { - /** If `true`, replaces the current history entry rather than creating a new one. */ + /** + * If `true`, replaces the current history entry rather than creating a new one. + * @default false + */ replace?: boolean; /** @deprecated Use `replace` instead. */ replaceState?: boolean; - /** If `true`, updates the URL and `page.state` without navigating. */ + /** + * If `true`, updates the URL and `page.state` without navigating. + * @default false + */ shallow?: boolean; - /** If `true`, preserves the browser's scroll position. */ + /** + * If `true`, preserves the browser's scroll position. + * @default false + */ noScroll?: boolean; - /** If `true`, keeps the currently focused element focused. */ + /** + * If `true`, keeps the currently focused element focused. + * @default false + */ keepFocus?: boolean; - /** If `true`, reruns all `load` functions and queries of the page. */ + /** + * If `true`, reruns all `load` functions and queries of the page. + * @default false + */ refreshAll?: boolean; /** Causes any `load` functions to rerun if they depend on one of the URLs. */ invalidate?: Array boolean)>; @@ -1282,7 +1297,10 @@ export interface GotoOptions { invalidateAll?: boolean; /** An optional object that will be available as `page.state`. */ state?: App.PageState; - /** If `true`, `page.state` will be restored after a full page reload. */ + /** + * If `true`, `page.state` will be restored after a full page reload. + * @default false + */ persistState?: boolean; } @@ -1481,7 +1499,7 @@ export interface Page< */ state: App.PageState; /** - * Information about the target of the most recent shallow navigation, or `null` if no shallow navigation has occurred. + * Information about the target of the current shallow navigation, or `null` if no shallow navigation has occurred. */ shallow: { /** Parameters of the target route, or `null` if the URL does not resolve to a route. */ diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 250743a2ada1..038683460487 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2374,7 +2374,7 @@ let warned_on_replace_state_function = false; * If the URL does not resolve to a route within the app, the returned promise will reject unless `shallow` is `true`. * For external URLs, use `window.location = url` to perform a full-page navigation instead of calling `goto(url)`. * - * @param {string | URL | null} url Where to navigate to. Note that if you've set [`config.paths.base`](https://svelte.dev/docs/kit/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app. + * @param {string | URL} url Where to navigate to. Note that if you've set [`config.paths.base`](https://svelte.dev/docs/kit/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app. * @param {import('@sveltejs/kit').GotoOptions} [opts] Options related to the navigation * @returns {Promise} */ @@ -2392,13 +2392,6 @@ export async function goto(url, opts = {}) { const replace = opts.replace ?? opts.replaceState ?? false; - if (url === null) { - // Untrack to avoid triggering outer reactive contexts because we access page.X inside - return untrack(() => - update_state(null, opts.state ?? {}, replace, opts.persistState ?? false, 'goto') - ); - } - url = new URL(resolve_url(url)); if (url.origin !== origin) { @@ -2665,7 +2658,7 @@ export async function replaceState(url, state, options = {}) { } /** - * @param {string | URL | null} url + * @param {string | URL} url * @param {App.PageState} state * @param {boolean} replace * @param {boolean} persist @@ -2686,15 +2679,15 @@ async function update_state(url, state, replace, persist, caller) { } } - const resolved = !url ? null : resolve_url(url); - const intent = resolved ? await get_navigation_intent(resolved, false) : undefined; + const resolved = resolve_url(url); + const intent = await get_navigation_intent(resolved, false); const nav = // For backwards compatibility we don't trigger navigation hooks etc for push/replaceState - resolved && caller === 'goto' + url !== '' && caller === 'goto' ? _before_navigate({ url: resolved, type: 'goto', intent, shallow: true }) : undefined; - if (resolved && !nav && caller === 'goto') return; + if (!nav && caller === 'goto') return; const nav_token = {}; @@ -2710,17 +2703,13 @@ async function update_state(url, state, replace, persist, caller) { const entry = { [HISTORY_INDEX]: (current_history_index += replace ? 0 : 1), [NAVIGATION_INDEX]: current_navigation_index, - ...((resolved || page.shallow) && { [PAGE_URL_KEY]: page.url.href }), + [PAGE_URL_KEY]: page.url.href, [STATES_PERSISTED_KEY]: persist, [STATES_KEY]: state }; const fn = replace ? history.replaceState : history.pushState; - if (resolved) { - fn.call(history, entry, '', resolved); - } else { - fn.call(history, entry, ''); - } + fn.call(history, entry, '', resolved); if (!replace) { has_navigated = true; @@ -2748,13 +2737,11 @@ async function update_state(url, state, replace, persist, caller) { } page.state = state; - if (resolved) { - page.shallow = { - params: intent?.params ?? null, - route: intent ? { id: intent.route.id } : null, - url: resolved - }; - } + page.shallow = { + params: intent?.params ?? null, + route: intent ? { id: intent.route.id } : null, + url: resolved + }; if (!nav) return; diff --git a/packages/kit/test/ambient.d.ts b/packages/kit/test/ambient.d.ts index 2d406b3110fe..8e27e47df4df 100644 --- a/packages/kit/test/ambient.d.ts +++ b/packages/kit/test/ambient.d.ts @@ -1,4 +1,4 @@ -import { AfterNavigate, BeforeNavigate } from '@sveltejs/kit'; +import { AfterNavigate, BeforeNavigate, GotoOptions } from '@sveltejs/kit'; declare global { interface Window { @@ -6,17 +6,7 @@ declare global { started: boolean; } - const goto: ( - href: string | null, - opts?: { - replace?: boolean; - replaceState?: boolean; - shallow?: boolean; - persistState?: boolean; - state?: App.PageState; - noScroll?: boolean; - } - ) => Promise; + const goto: (href: string, opts?: GotoOptions) => Promise; const invalidate: (url: string) => Promise; const preloadData: (url: string) => Promise; diff --git a/packages/kit/test/apps/basics/src/routes/scroll/push-state/+page.svelte b/packages/kit/test/apps/basics/src/routes/scroll/push-state/+page.svelte index a9224aabf8dd..d97e4f7d3ada 100644 --- a/packages/kit/test/apps/basics/src/routes/scroll/push-state/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/scroll/push-state/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/state'; function handleClick() { - goto(null, { state: { active: true } }); + goto('', { shallow: true, state: { active: true } }); } diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte index 8f86cf6e6538..9c586d110847 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte @@ -7,7 +7,7 @@ let resolved = $state(null); function one() { - void goto(null, { state: { active: true } }); + void goto('', { shallow: true, state: { active: true } }); } function two() { @@ -34,12 +34,12 @@ - diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index c04f0c45c6d4..6328eda846f5 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1684,8 +1684,33 @@ test.describe('Shallow routing', () => { await page.locator('[data-id="one"]').click(); await expect(page.locator('p')).toHaveText('active: true'); - await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); - expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([]); + await expect(page.locator('[data-id="shallow"]')).toHaveText( + '/shallow-routing/push-state /shallow-routing/push-state {}' + ); + expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([ + { + hook: 'before', + params: {}, + path: '/shallow-routing/push-state', + route: '/shallow-routing/push-state', + shallow: true, + type: 'goto' + }, + { + hook: 'on', + shallow: true, + type: 'goto' + }, + { + hook: 'after', + shallow: true, + state: 'active: true', + type: 'goto' + }, + { + hook: 'complete' + } + ]); await page.goBack(); await expect(page.locator('p')).toHaveText('active: false'); @@ -1793,17 +1818,15 @@ test.describe('Shallow routing', () => { ]); }); - test('Adds state without a shallow navigation and does not restore it by default', async ({ - baseURL, - page - }) => { + test('Adds state and does not restore it by default', async ({ baseURL, page }) => { await page.goto('/shallow-routing/push-state'); await page.locator('[data-id="state-only"]').click(); expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state`); await expect(page.locator('p')).toHaveText('active: true'); - await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); - expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([]); + await expect(page.locator('[data-id="shallow"]')).toHaveText( + '/shallow-routing/push-state /shallow-routing/push-state {}' + ); await page.reload(); await expect(page.locator('p')).toHaveText('active: false'); @@ -1859,7 +1882,7 @@ test.describe('Shallow routing', () => { await expect(page.locator('[data-id="shallow"]')).toHaveText( '/shallow-routing/push-state/a /shallow-routing/push-state/a {}' ); - expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([]); + expect(await page.evaluate(() => window.shallow_navigation_log.length)).toBeGreaterThan(0); await page.goBack(); await page.goForward(); @@ -1935,8 +1958,24 @@ test.describe('Shallow routing', () => { await page.locator('[data-id="one"]').click(); await expect(page.locator('p')).toHaveText('active: true'); - await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); - expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([]); + await expect(page.locator('[data-id="shallow"]')).toHaveText('/shallow-routing/replace-state'); + expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([ + { + hook: 'before', + shallow: true, + type: 'goto' + }, + { + hook: 'on', + shallow: true, + type: 'goto' + }, + { + hook: 'after', + shallow: true, + type: 'goto' + } + ]); await page.goBack(); expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state/b`); @@ -1970,7 +2009,7 @@ test.describe('Shallow routing', () => { await expect(page.locator('[data-id="shallow"]')).toHaveText( '/shallow-routing/replace-state/a' ); - expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([]); + expect(await page.evaluate(() => window.shallow_navigation_log.length)).toBeGreaterThan(0); await page.goBack(); expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state/b`); @@ -1988,8 +2027,7 @@ test.describe('Shallow routing', () => { expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state`); await expect(page.locator('p')).toHaveText('active: true'); - await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); - expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([]); + await expect(page.locator('[data-id="shallow"]')).toHaveText('/shallow-routing/replace-state'); await page.reload(); await expect(page.locator('p')).toHaveText('active: true'); diff --git a/packages/kit/test/types.d.ts b/packages/kit/test/types.d.ts index 2ad61d8f4f5f..4ae47f7d3b65 100644 --- a/packages/kit/test/types.d.ts +++ b/packages/kit/test/types.d.ts @@ -8,22 +8,13 @@ import { } from '@playwright/test'; import { IncomingMessage, ServerResponse } from 'node:http'; import '../types/index.d.ts'; -import { AfterNavigate, BeforeNavigate } from '@sveltejs/kit'; +import { AfterNavigate, BeforeNavigate, GotoOptions } from '@sveltejs/kit'; export const test: TestType< Omit & PlaywrightTestOptions & { app: { - goto( - url: string | null, - opts?: { - replace?: boolean; - replaceState?: boolean; - shallow?: boolean; - persistState?: boolean; - state?: App.PageState; - } - ): Promise; + goto(url: string, opts?: GotoOptions): Promise; invalidate(url: string): Promise; beforeNavigate(fn: (navigation: BeforeNavigate) => void | boolean): void; afterNavigate(fn: (navigation: AfterNavigate) => void): void; From 8ee6d07c37fafbbd1437a68100c5e1ff545eaa17 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 22 Jul 2026 23:35:36 +0200 Subject: [PATCH 09/29] remove goto('', { shallow: true }) special-case of not triggering navigation hooks --- documentation/docs/30-advanced/67-shallow-routing.md | 3 --- packages/kit/src/runtime/client/client.js | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index 2143e0ad37ed..fd4bd1a48eff 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -66,9 +66,6 @@ Once shallow routing is active, `page.shallow` is set with the visible URL, para A regular `goto` call without `shallow: true`, or a link click, exits shallow routing. -> [!NOTE] -> `goto('', { shallow: true, state: ... });` does _not_ trigger navigation hooks. - > [!NOTE] > In SvelteKit 2 this functionality was achieved using `pushState` and `replaceState`, which are now deprecated. Use `goto` with `shallow: true` instead, and use the `replace` option when replacing the current history entry. diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 038683460487..80c81acc4c96 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2683,7 +2683,7 @@ async function update_state(url, state, replace, persist, caller) { const intent = await get_navigation_intent(resolved, false); const nav = // For backwards compatibility we don't trigger navigation hooks etc for push/replaceState - url !== '' && caller === 'goto' + caller === 'goto' ? _before_navigate({ url: resolved, type: 'goto', intent, shallow: true }) : undefined; From 44d62fb539a9d2774ca341ea37cd66f4587dee91 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 22 Jul 2026 23:52:15 +0200 Subject: [PATCH 10/29] doc tweaks --- .../docs/30-advanced/67-shallow-routing.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index fd4bd1a48eff..24b0b91e5ebe 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -4,9 +4,9 @@ title: Page state & shallow routing As you navigate around a SvelteKit app, you create _history entries_. Clicking the back and forward buttons traverses through this list of entries, re-running any `load` functions and replacing page components as necessary. -Sometimes, it's useful to create history entries _without_ navigating. For example, you might want to show a modal dialog that the user can dismiss by navigating back. This is particularly valuable on mobile devices, where swipe gestures are often more natural than interacting directly with the UI. In these cases, a modal that is _not_ associated with a history entry can be a source of frustration, as a user may swipe backwards in an attempt to dismiss it and find themselves on the wrong page. +Sometimes, it's useful to create history entries _without_ performing a full navigation. For example, you might want to show a modal dialog that the user can dismiss by navigating back. This is particularly valuable on mobile devices, where swipe gestures are often more natural than interacting directly with the UI. In these cases, a modal that is _not_ associated with a history entry can be a source of frustration, as a user may swipe backwards in an attempt to dismiss it and find themselves on the wrong page. -SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, which allows you to associate state with a history entry without navigating. For example, to implement a history-driven modal: +SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, which allows you to associate state with a history entry without navigating by using its `shallow: true` option. For example, to implement a history-driven modal: ```svelte @@ -30,15 +30,14 @@ SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, {/if} ``` +Because the navigation doesn't run `load` functions or switch to a different `+page.svelte`, we call this **shallow routing**. + State can be accessed through the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). The modal can be dismissed by navigating back (unsetting `page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically. -Because there was no real navigation but the history stack still updated, we call this shallow routing. - -## Shallow routing -The above writes to the history state but not the URL. You can also write to the URL without performing a real navigation: +You can also update the visible URL during a shallow navigation: ```js goto('/photos/1', { @@ -47,7 +46,7 @@ goto('/photos/1', { }); ``` -This updates the visible URL and `page.state` without running `load` functions or changing the rendered page. [`beforeNavigate`]($app-navigation#beforeNavigate), [`onNavigate`]($app-navigation#onNavigate) and [`afterNavigate`]($app-navigation#afterNavigate) will run with `navigation.type === 'goto'` and `navigation.shallow === true`. +Regardless of whether you choose to update the visible URL or not, [`beforeNavigate`]($app-navigation#beforeNavigate), [`onNavigate`]($app-navigation#onNavigate) and [`afterNavigate`]($app-navigation#afterNavigate) will run with `navigation.type === 'goto'` and `navigation.shallow === true`. Once shallow routing is active, `page.shallow` is set with the visible URL, parameters and route id. `page.url`, `page.params` and `page.route` continue to describe the page that was last rendered as a result of an actual navigation. @@ -64,14 +63,14 @@ Once shallow routing is active, `page.shallow` is set with the visible URL, para ``` -A regular `goto` call without `shallow: true`, or a link click, exits shallow routing. +A regular `goto` call without `shallow: true`, or a standard link click, exits shallow routing. > [!NOTE] > In SvelteKit 2 this functionality was achieved using `pushState` and `replaceState`, which are now deprecated. Use `goto` with `shallow: true` instead, and use the `replace` option when replacing the current history entry. ## Routing options -The above examples set state and create a new navigation entry. If you don't want that, you can replace the existing navigation entry instead: +By default, the above examples create a new navigation entry in the history stack. If you don't want that, you can replace the existing navigation entry instead: ```js goto(url, { @@ -89,7 +88,8 @@ goto(url, { }); ``` -> [!NOTE] `page.state` is only populated after JavaScript loads, which can cause flickering UI. Use it carefully. +> [!NOTE] +> `page.state` is only populated after JavaScript loads, which can cause flickering UI. Use it carefully. ## Loading data for a route @@ -154,4 +154,4 @@ During server-side rendering, `page.state` is always an empty object. Shallow routing is a feature that requires JavaScript to work. Be mindful when using it and try to think of sensible fallback behavior in case JavaScript isn't available. -If you navigate to another page via shallow routing, reloading on that route will not start the app in shallow routing mode. Instead the actual page on that URL is loaded. On the server this is unavoidable (because history state isn't available at that point), and hence it would mean too much of a UI flicker if it would change the page and enter shallow routing once JavaScript is loaded. This is irregardless of the `persistState` option. +If you navigate to another page via shallow routing, reloading on that route will not start the app in shallow routing mode. Instead, the actual page on that URL is loaded - in the above example, you would land on `src/routes/photos/[id]/+page.svelte` instead of `src/routes/photos/+page.svelte`. On the server, this is unavoidable (because history state isn't available at that point), and hence it would mean too much of a UI flicker if it changed the page and entered shallow routing once JavaScript is loaded. This is regardless of the `persistState` option. From 9412cfc75e325a0491787b5eb0b5100aa9efe0d5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 23 Jul 2026 00:14:24 +0200 Subject: [PATCH 11/29] oops --- packages/kit/src/runtime/client/client.js | 8 ++--- packages/kit/types/index.d.ts | 38 +++++++++++++++++------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 80c81acc4c96..f54859533dc2 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2608,7 +2608,7 @@ export async function preloadCode(pathname) { /** * Programmatically create a new history entry with the given `page.state`. Used for [shallow routing](https://svelte.dev/docs/kit/shallow-routing). * - * @deprecated Use `goto(url, { state, shallow: true })` instead. To keep the current URL, use `goto(null, { state })`. + * @deprecated Use `goto(url, { state, shallow: true })` instead. * @param {string | URL} url * @param {App.PageState} state * @param {Object} [options] @@ -2623,7 +2623,7 @@ export async function pushState(url, state, options = {}) { if (DEV && !warned_on_push_state) { warned_on_push_state = true; console.warn( - '`pushState(...)` is deprecated. Use `goto(url, { state, shallow: true })` instead. To keep the current URL, use `goto(null, { state })`.' + '`pushState(...)` is deprecated. Use `goto(url, { state, shallow: true })` instead.' ); } @@ -2634,7 +2634,7 @@ export async function pushState(url, state, options = {}) { /** * Programmatically replace the current history entry with the given `page.state`. Used for [shallow routing](https://svelte.dev/docs/kit/shallow-routing). * - * @deprecated Use `goto(url, { state, shallow: true, replace: true })` instead. To keep the current URL, use `goto(null, { state, replace: true })`. + * @deprecated Use `goto(url, { state, shallow: true, replace: true })` instead. * @param {string | URL} url * @param {App.PageState} state * @param {Object} [options] @@ -2649,7 +2649,7 @@ export async function replaceState(url, state, options = {}) { if (DEV && !warned_on_replace_state_function) { warned_on_replace_state_function = true; console.warn( - '`replaceState(...)` is deprecated. Use `goto(url, { state, shallow: true, replace: true })` instead. To keep the current URL, use `goto(null, { state, replace: true })`.' + '`replaceState(...)` is deprecated. Use `goto(url, { state, shallow: true, replace: true })` instead.' ); } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 83e2407194eb..b9d501ef7784 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1236,17 +1236,32 @@ declare module '@sveltejs/kit' { } export interface GotoOptions { - /** If `true`, replaces the current history entry rather than creating a new one. */ + /** + * If `true`, replaces the current history entry rather than creating a new one. + * @default false + */ replace?: boolean; /** @deprecated Use `replace` instead. */ replaceState?: boolean; - /** If `true`, updates the URL and `page.state` without navigating. */ + /** + * If `true`, updates the URL and `page.state` without navigating. + * @default false + */ shallow?: boolean; - /** If `true`, preserves the browser's scroll position. */ + /** + * If `true`, preserves the browser's scroll position. + * @default false + */ noScroll?: boolean; - /** If `true`, keeps the currently focused element focused. */ + /** + * If `true`, keeps the currently focused element focused. + * @default false + */ keepFocus?: boolean; - /** If `true`, reruns all `load` functions and queries of the page. */ + /** + * If `true`, reruns all `load` functions and queries of the page. + * @default false + */ refreshAll?: boolean; /** Causes any `load` functions to rerun if they depend on one of the URLs. */ invalidate?: Array boolean)>; @@ -1254,7 +1269,10 @@ declare module '@sveltejs/kit' { invalidateAll?: boolean; /** An optional object that will be available as `page.state`. */ state?: App.PageState; - /** If `true`, `page.state` will be restored after a full page reload. */ + /** + * If `true`, `page.state` will be restored after a full page reload. + * @default false + */ persistState?: boolean; } @@ -1453,7 +1471,7 @@ declare module '@sveltejs/kit' { */ state: App.PageState; /** - * Information about the target of the most recent shallow navigation, or `null` if no shallow navigation has occurred. + * Information about the target of the current shallow navigation, or `null` if no shallow navigation has occurred. */ shallow: { /** Parameters of the target route, or `null` if the URL does not resolve to a route. */ @@ -3195,7 +3213,7 @@ declare module '$app/navigation' { * @param url Where to navigate to. Note that if you've set [`config.paths.base`](https://svelte.dev/docs/kit/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app. * @param opts Options related to the navigation * */ - export function goto(url: string | URL | null, opts?: import("@sveltejs/kit").GotoOptions): Promise; + export function goto(url: string | URL, opts?: import("@sveltejs/kit").GotoOptions): Promise; /** * Causes any `load` functions belonging to the currently active page to re-run if they depend on the `url` in question, via `fetch` or `depends`. Returns a `Promise` that resolves when the page is subsequently updated. * @@ -3265,7 +3283,7 @@ declare module '$app/navigation' { /** * Programmatically create a new history entry with the given `page.state`. Used for [shallow routing](https://svelte.dev/docs/kit/shallow-routing). * - * @deprecated Use `goto(url, { state, shallow: true })` instead. To keep the current URL, use `goto(null, { state })`. + * @deprecated Use `goto(url, { state, shallow: true })` instead. * */ export function pushState(url: string | URL, state: App.PageState, options?: { persist?: boolean | undefined; @@ -3273,7 +3291,7 @@ declare module '$app/navigation' { /** * Programmatically replace the current history entry with the given `page.state`. Used for [shallow routing](https://svelte.dev/docs/kit/shallow-routing). * - * @deprecated Use `goto(url, { state, shallow: true, replace: true })` instead. To keep the current URL, use `goto(null, { state, replace: true })`. + * @deprecated Use `goto(url, { state, shallow: true, replace: true })` instead. * */ export function replaceState(url: string | URL, state: App.PageState, options?: { persist?: boolean | undefined; From 0e8a5d200114e2b15ec15938faf46d0210bfa5f4 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 23 Jul 2026 00:26:07 +0200 Subject: [PATCH 12/29] gah --- packages/kit/test/apps/basics/test/client.test.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 6328eda846f5..6c4a9968d7f4 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1677,7 +1677,7 @@ test.describe('untrack', () => { }); }); -test.describe('Shallow routing', () => { +test.describe.only('Shallow routing', () => { test('Adds state without changing the current URL', async ({ page }) => { await page.goto('/shallow-routing/push-state'); await expect(page.locator('p')).toHaveText('active: false'); @@ -2060,9 +2060,7 @@ test.describe('Shallow routing', () => { await expect(page.locator('p')).toHaveText('count: 1'); expect(warnings.filter((warning) => warning.includes('pushState(...)'))).toEqual( process.env.DEV - ? [ - '`pushState(...)` is deprecated. Use `goto(url, { state, shallow: true })` instead. To keep the current URL, use `goto(null, { state })`.' - ] + ? ['`pushState(...)` is deprecated. Use `goto(url, { state, shallow: true })` instead.'] : [] ); }); @@ -2083,7 +2081,7 @@ test.describe('Shallow routing', () => { expect(warnings.filter((warning) => warning.includes('replaceState(...)'))).toEqual( process.env.DEV ? [ - '`replaceState(...)` is deprecated. Use `goto(url, { state, shallow: true, replace: true })` instead. To keep the current URL, use `goto(null, { state, replace: true })`.' + '`replaceState(...)` is deprecated. Use `goto(url, { state, shallow: true, replace: true })` instead.' ] : [] ); From a4d4bb2eb4779be6891b0531a00873a2a08dba3b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 23 Jul 2026 00:31:58 +0200 Subject: [PATCH 13/29] gah --- packages/kit/test/apps/basics/test/client.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 6c4a9968d7f4..46794ba321c2 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1677,7 +1677,7 @@ test.describe('untrack', () => { }); }); -test.describe.only('Shallow routing', () => { +test.describe('Shallow routing', () => { test('Adds state without changing the current URL', async ({ page }) => { await page.goto('/shallow-routing/push-state'); await expect(page.locator('p')).toHaveText('active: false'); From 969531c3a391557017dce82dab14009caaebeb23 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 23 Jul 2026 00:39:52 +0200 Subject: [PATCH 14/29] make tests more robust --- .../kit/test/apps/basics/test/client.test.js | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 46794ba321c2..c09eaf977454 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1736,13 +1736,13 @@ test.describe('Shallow routing', () => { await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); await page.goBack(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/push-state`); await expect(page.locator('h1')).toHaveText('parent'); await expect(page.locator('p')).toHaveText('active: false'); // Going forward again should reactivate shallow routing await page.goForward(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state/a`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/push-state/a`); await expect(page.locator('h1')).toHaveText('parent'); await expect(page.locator('p')).toHaveText('active: true'); await expect(page.locator('[data-id="shallow"]')).toHaveText( @@ -1755,13 +1755,13 @@ test.describe('Shallow routing', () => { await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); await page.locator('[data-id="shallow-b"]').click(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state/b`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/push-state/b`); await expect(page.locator('h1')).toHaveText('a'); await expect(page.locator('p')).toHaveText('active: true'); // After reload + pushState + back, we should reactivate shallow routing await page.goBack(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state/a`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/push-state/a`); await expect(page.locator('h1')).toHaveText('parent'); await expect(page.locator('p')).toHaveText('active: true'); await expect(page.locator('[data-id="shallow"]')).toHaveText( @@ -1803,7 +1803,7 @@ test.describe('Shallow routing', () => { await page.goto('/shallow-routing/push-state'); await page.locator('[data-id="cancel"]').click(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/push-state`); await expect(page.locator('p')).toHaveText('active: false'); await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([ @@ -1822,7 +1822,7 @@ test.describe('Shallow routing', () => { await page.goto('/shallow-routing/push-state'); await page.locator('[data-id="state-only"]').click(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/push-state`); await expect(page.locator('p')).toHaveText('active: true'); await expect(page.locator('[data-id="shallow"]')).toHaveText( '/shallow-routing/push-state /shallow-routing/push-state {}' @@ -1947,7 +1947,7 @@ test.describe('Shallow routing', () => { await page.goBack(); await page.goForward(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state/a`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/push-state/a`); await expect(page.locator('h1')).toHaveText('parent'); await expect(page.locator('p')).toHaveText('active: true'); }); @@ -1978,11 +1978,11 @@ test.describe('Shallow routing', () => { ]); await page.goBack(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state/b`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/replace-state/b`); await expect(page.locator('h1')).toHaveText('b'); await page.goForward(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/replace-state`); await expect(page.locator('h1')).toHaveText('parent'); await expect(page.locator('p')).toHaveText('active: true'); }); @@ -2012,11 +2012,11 @@ test.describe('Shallow routing', () => { expect(await page.evaluate(() => window.shallow_navigation_log.length)).toBeGreaterThan(0); await page.goBack(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state/b`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/replace-state/b`); await expect(page.locator('h1')).toHaveText('b'); await page.goForward(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state/a`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/replace-state/a`); await expect(page.locator('h1')).toHaveText('parent'); await expect(page.locator('p')).toHaveText('active: true'); }); @@ -2025,7 +2025,7 @@ test.describe('Shallow routing', () => { await page.goto('/shallow-routing/replace-state'); await page.locator('[data-id="state-only"]').click(); - expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state`); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/replace-state`); await expect(page.locator('p')).toHaveText('active: true'); await expect(page.locator('[data-id="shallow"]')).toHaveText('/shallow-routing/replace-state'); From 1ffe4642b0af8eebe01b858172f9ff25c6e20663 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 24 Jul 2026 00:38:51 +0200 Subject: [PATCH 15/29] Preserve focus option on popstate; take keepfocus/noscroll options into account for shallow navigations and default them to true Fixes #11452 --- .../docs/30-advanced/67-shallow-routing.md | 10 + packages/kit/src/exports/public.d.ts | 4 +- packages/kit/src/runtime/client/client.js | 321 +++++++++++------- packages/kit/src/runtime/client/constants.js | 9 +- .../shallow-routing/push-state/+page.svelte | 12 + .../kit/test/apps/basics/test/client.test.js | 45 +++ packages/kit/types/index.d.ts | 12 +- 7 files changed, 282 insertions(+), 131 deletions(-) diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index 24b0b91e5ebe..281636f90e53 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -88,6 +88,16 @@ goto(url, { }); ``` +Shallow navigations preserve the current scroll position and focused element by default. You can opt out of either behavior with `noScroll: false` or `keepFocus: false`: + +```js +goto(url, { + shallow: true, + noScroll: false, + keepFocus: false +}); +``` + > [!NOTE] > `page.state` is only populated after JavaScript loads, which can cause flickering UI. Use it carefully. diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 534650f7c2e5..7bfe87d60fa4 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1278,12 +1278,12 @@ export interface GotoOptions { shallow?: boolean; /** * If `true`, preserves the browser's scroll position. - * @default false + * @default false, or true when `shallow` is true */ noScroll?: boolean; /** * If `true`, keeps the currently focused element focused. - * @default false + * @default false, or true when `shallow` is true */ keepFocus?: boolean; /** diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index f54859533dc2..aa03d034833d 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -24,14 +24,10 @@ import { import { base } from '$app/paths/internal/client'; import * as devalue from 'devalue'; import { - HISTORY_INDEX, - NAVIGATION_INDEX, + HISTORY_INFO_KEY, + HISTORY_METADATA_KEY, PRELOAD_PRIORITIES, - SCROLL_KEY, - STATES_KEY, - STATES_PERSISTED_KEY, - SNAPSHOT_KEY, - PAGE_URL_KEY + SNAPSHOT_KEY } from './constants.js'; import { validate_page_exports } from '../../utils/exports.js'; import { noop } from '../../utils/functions.js'; @@ -53,6 +49,17 @@ import { asClassComponent } from 'svelte/legacy'; const Root = asClassComponent(RootModern); +/** + * @typedef {{ + * historyIndex: number; + * navigationIndex: number; + * pageUrl?: string; + * state: Record; + * persistState: boolean; + * keepFocus: boolean; + * }} HistoryMetadata + */ + export { load_css }; const ICON_REL_ATTRIBUTES = new Set(['icon', 'shortcut icon', 'apple-touch-icon']); @@ -77,15 +84,14 @@ let rendering_error = null; */ const resetters = []; -// We track the scroll position associated with each history entry in sessionStorage, +// We track information associated with each history entry in sessionStorage, // rather than on history.state itself, because when navigation is driven by -// popstate it's too late to update the scroll position associated with the +// popstate it's too late to access the options or update the focus position associated with the // state we're navigating from /** - * history index -> { x, y } - * @type {Record} + * @type {Record} */ -const scroll_positions = storage.get(SCROLL_KEY) ?? {}; +const history_info = storage.get(HISTORY_INFO_KEY) ?? {}; /** * navigation index -> any @@ -141,8 +147,60 @@ if (DEV && BROWSER) { } /** @param {number} index */ -function update_scroll_positions(index) { - scroll_positions[index] = scroll_state(); +function capture_scroll(index) { + history_info[index].scroll = scroll_state(); +} + +/** + * @param {number} index + * @param {Pick} options + */ +function set_history_options(index, options) { + history_info[index] = { + ...history_info[index], + keepFocus: options.keepFocus + }; +} + +/** @param {boolean | undefined} keepfocus */ +function blur_active_element(keepfocus) { + if ( + !keepfocus && + document.activeElement instanceof HTMLElement && + document.activeElement !== document.body + ) { + document.activeElement.blur(); + } +} + +/** + * @param {URL} url + * @param {{ x: number; y: number } | null | undefined} scroll + * @param {boolean | undefined} keepfocus + * @param {Element | null} active_element + */ +function reset_scroll_and_focus(url, scroll, keepfocus, active_element) { + /** @type {Element | null | ''} */ + let deep_linked = null; + + if (autoscroll) { + if (scroll) { + scrollTo(scroll.x, scroll.y); + } else if ((deep_linked = url.hash && document.getElementById(get_id(url)))) { + deep_linked.scrollIntoView(); + } else { + scrollTo(0, 0); + } + } + + const changed_focus = + document.activeElement !== active_element && document.activeElement !== document.body; + + if (!keepfocus && !changed_focus) { + reset_focus(url, !deep_linked); + } + + autoscroll = true; } /** @@ -153,8 +211,8 @@ function clear_onward_history(current_history_index, current_navigation_index) { // if we navigated back, then pushed a new state, we can // release memory by pruning the scroll/snapshot lookup let i = current_history_index + 1; - while (scroll_positions[i]) { - delete scroll_positions[i]; + while (history_info[i]) { + delete history_info[i]; i += 1; } @@ -285,7 +343,8 @@ let started = false; let autoscroll = true; let updating = false; let is_navigating = false; -let hash_navigating = false; +/** @type {HistoryMetadata | null} */ +let hash_navigating = null; /** True as soon as there happened one client-side navigation (excluding the SvelteKit-initialized initial one when in SPA mode) */ let has_navigated = false; @@ -300,6 +359,14 @@ let current_history_index; /** @type {number} */ let current_navigation_index; +/** + * @param {any} [state] + * @returns {HistoryMetadata | undefined} + */ +function get_history_metadata(state = history.state) { + return state?.[HISTORY_METADATA_KEY]; +} + /** @type {{}} Token for the latest navigation. Updated on new navigations */ let navigation_token; @@ -382,8 +449,9 @@ export async function start(_app, _target, hydrate) { void default_layout_loader(); void default_error_loader(); - current_history_index = history.state?.[HISTORY_INDEX]; - current_navigation_index = history.state?.[NAVIGATION_INDEX]; + const history_metadata = get_history_metadata(); + current_history_index = history_metadata?.historyIndex ?? 0; + current_navigation_index = history_metadata?.navigationIndex ?? 0; if (!current_history_index) { // we use Date.now() as an offset so that cross-document navigations @@ -394,16 +462,27 @@ export async function start(_app, _target, hydrate) { history.replaceState( { ...history.state, - [HISTORY_INDEX]: current_history_index, - [NAVIGATION_INDEX]: current_navigation_index + [HISTORY_METADATA_KEY]: { + historyIndex: current_history_index, + navigationIndex: current_navigation_index, + state: {}, + persistState: false, + noScroll: false, + keepFocus: false + } }, '' ); } + set_history_options( + current_history_index, + /** @type {HistoryMetadata} */ (get_history_metadata()) + ); + // if we reload the page, or Cmd-Shift-T back to it, // recover scroll position - const scroll = scroll_positions[current_history_index]; + const scroll = history_info[current_history_index]?.scroll; function restore_scroll() { if (scroll) { history.scrollRestoration = 'manual'; @@ -420,8 +499,8 @@ export async function start(_app, _target, hydrate) { type: 'enter', url: resolve_url(app.hash ? decode_hash(new URL(location.href)) : location.href), replace_state: true, - state: history.state?.[STATES_PERSISTED_KEY] ? (history.state[STATES_KEY] ?? {}) : {}, - persist_state: history.state?.[STATES_PERSISTED_KEY] ?? false + state: history_metadata?.persistState ? history_metadata.state : {}, + persist_state: history_metadata?.persistState ?? false }); restore_scroll(); @@ -544,8 +623,8 @@ function restore_snapshot(index) { } function persist_state() { - update_scroll_positions(current_history_index); - storage.set(SCROLL_KEY, scroll_positions); + capture_scroll(current_history_index); + storage.set(HISTORY_INFO_KEY, history_info); capture_snapshot(current_navigation_index); storage.set(SNAPSHOT_KEY, snapshots); @@ -753,7 +832,7 @@ async function initialize(result, target, hydrate) { from: null, to: { ...nav, - scroll: scroll_positions[current_history_index] ?? scroll_state() + scroll: history_info[current_history_index]?.scroll ?? scroll_state() }, willUnload: false, type: 'enter', @@ -1689,7 +1768,7 @@ function _before_navigate({ url, type, intent, delta, event, scroll, shallow = f * url: URL; * popped?: { * state: Record; - * scroll: { x: number, y: number }; + * scroll?: { x: number, y: number }; * delta: number; * shallow: { params: Record | null; route: { id: string } | null; url: URL } | null; * }; @@ -1860,7 +1939,7 @@ async function navigate({ updating = true; - update_scroll_positions(previous_history_index); + capture_scroll(previous_history_index); capture_snapshot(previous_navigation_index); // ensure the url pathname matches the page's trailing slash option @@ -1875,14 +1954,18 @@ async function navigate({ const change = replace_state ? 0 : 1; const entry = { - [HISTORY_INDEX]: (current_history_index += change), - [NAVIGATION_INDEX]: (current_navigation_index += change), - [STATES_PERSISTED_KEY]: persist_state, - [STATES_KEY]: state + [HISTORY_METADATA_KEY]: /** @satisfies {HistoryMetadata} */ ({ + historyIndex: (current_history_index += change), + navigationIndex: (current_navigation_index += change), + state, + persistState: persist_state, + keepFocus: keepfocus ?? false + }) }; const fn = replace_state ? history.replaceState : history.pushState; fn.call(history, entry, '', url); + set_history_options(current_history_index, entry[HISTORY_METADATA_KEY]); if (!replace_state) { clear_onward_history(current_history_index, current_navigation_index); @@ -1955,13 +2038,7 @@ async function navigate({ // Remove focus before updating the component tree, so that blur/focusout // handlers fire while the old component's data is still valid (#14575) - if ( - !keepfocus && - document.activeElement instanceof HTMLElement && - document.activeElement !== document.body - ) { - document.activeElement.blur(); - } + blur_active_element(keepfocus); const fork = load_cache_fork && (await load_cache_fork); @@ -2014,39 +2091,12 @@ async function navigate({ Object.assign(navigation_result.props.page, rendering_error); } - // we reset scroll before dealing with focus, to avoid a flash of unscrolled content - /** @type {Element | null | ''} */ - let deep_linked = null; - - if (autoscroll) { - const scroll = popped ? popped.scroll : noscroll ? scroll_state() : null; - if (scroll) { - scrollTo(scroll.x, scroll.y); - } else if ((deep_linked = url.hash && document.getElementById(get_id(url)))) { - // Here we use `scrollIntoView` on the element instead of `scrollTo` - // because it natively supports the `scroll-margin` and `scroll-behavior` - // CSS properties. - deep_linked.scrollIntoView(); - } else { - scrollTo(0, 0); - } - } - - const changed_focus = - // reset focus only if any manual focus management didn't override it - document.activeElement !== activeElement && - // also refocus when activeElement is body already because the - // focus event might not have been fired on it yet - document.activeElement !== document.body; - - if (!keepfocus && !changed_focus) { - // We don't need to manually restore the scroll position if we're navigating - // to a fragment identifier. It is automatically done for us when we set the - // sequential navigation starting point with `location.replace` - reset_focus(url, !deep_linked); - } - - autoscroll = true; + reset_scroll_and_focus( + url, + popped ? popped.scroll : noscroll ? scroll_state() : null, + keepfocus, + activeElement + ); is_navigating = false; @@ -2405,7 +2455,17 @@ export async function goto(url, opts = {}) { if (opts.shallow) { // Untrack to avoid triggering outer reactive contexts because we access page.X inside return untrack(() => - update_state(url, opts.state ?? {}, replace, opts.persistState ?? false, 'goto') + update_state( + url, + opts.state ?? {}, + { + replace, + persist_state: opts.persistState ?? false, + noscroll: opts.noScroll ?? true, + keepfocus: opts.keepFocus ?? true + }, + 'goto' + ) ); } @@ -2611,11 +2671,9 @@ export async function preloadCode(pathname) { * @deprecated Use `goto(url, { state, shallow: true })` instead. * @param {string | URL} url * @param {App.PageState} state - * @param {Object} [options] - * @param {boolean} [options.persist] Whether to persist the state across a full page reload. Defaults to `false`. * @returns {Promise} */ -export async function pushState(url, state, options = {}) { +export async function pushState(url, state) { if (!BROWSER) { throw new Error('Cannot call pushState(...) on the server'); } @@ -2628,7 +2686,14 @@ export async function pushState(url, state, options = {}) { } // Untrack to avoid triggering outer reactive contexts because we access page.X inside - await untrack(() => update_state(url, state, false, options.persist ?? false, 'pushState')); + await untrack(() => + update_state( + url, + state, + { replace: false, persist_state: false, noscroll: true, keepfocus: true }, + 'pushState' + ) + ); } /** @@ -2637,11 +2702,9 @@ export async function pushState(url, state, options = {}) { * @deprecated Use `goto(url, { state, shallow: true, replace: true })` instead. * @param {string | URL} url * @param {App.PageState} state - * @param {Object} [options] - * @param {boolean} [options.persist] Whether to persist the state across a full page reload. Defaults to `false`. * @returns {Promise} */ -export async function replaceState(url, state, options = {}) { +export async function replaceState(url, state) { if (!BROWSER) { throw new Error('Cannot call replaceState(...) on the server'); } @@ -2654,17 +2717,23 @@ export async function replaceState(url, state, options = {}) { } // Untrack to avoid triggering outer reactive contexts because we access page.X inside - await untrack(() => update_state(url, state, true, options.persist ?? false, 'replaceState')); + await untrack(() => + update_state( + url, + state, + { replace: true, persist_state: false, noscroll: true, keepfocus: true }, + 'replaceState' + ) + ); } /** * @param {string | URL} url * @param {App.PageState} state - * @param {boolean} replace - * @param {boolean} persist + * @param {{ replace: boolean; persist_state: boolean; noscroll: boolean; keepfocus: boolean }} options * @param {'goto' | 'pushState' | 'replaceState'} caller */ -async function update_state(url, state, replace, persist, caller) { +async function update_state(url, state, { replace, persist_state, noscroll, keepfocus }, caller) { if (DEV) { if (!started) { throw new Error(`Cannot call ${caller}(...) before router is initialized`); @@ -2698,18 +2767,22 @@ async function update_state(url, state, replace, persist, caller) { updating = true; } - if (!replace) update_scroll_positions(current_history_index); + if (!replace) capture_scroll(current_history_index); const entry = { - [HISTORY_INDEX]: (current_history_index += replace ? 0 : 1), - [NAVIGATION_INDEX]: current_navigation_index, - [PAGE_URL_KEY]: page.url.href, - [STATES_PERSISTED_KEY]: persist, - [STATES_KEY]: state + [HISTORY_METADATA_KEY]: /** @satisfies {HistoryMetadata} */ ({ + historyIndex: (current_history_index += replace ? 0 : 1), + navigationIndex: current_navigation_index, + pageUrl: page.url.href, + state, + persistState: persist_state, + keepFocus: keepfocus + }) }; const fn = replace ? history.replaceState : history.pushState; fn.call(history, entry, '', resolved); + set_history_options(current_history_index, entry[HISTORY_METADATA_KEY]); if (!replace) { has_navigated = true; @@ -2736,6 +2809,8 @@ async function update_state(url, state, replace, persist, caller) { } } + blur_active_element(keepfocus); + page.state = state; page.shallow = { params: intent?.params ?? null, @@ -2745,6 +2820,8 @@ async function update_state(url, state, replace, persist, caller) { if (!nav) return; + const { activeElement } = document; + await settled(); if (navigation_token !== nav_token) { @@ -2753,6 +2830,8 @@ async function update_state(url, state, replace, persist, caller) { return; } + reset_scroll_and_focus(resolved, noscroll ? scroll_state() : null, keepfocus, activeElement); + is_navigating = false; nav.fulfil(undefined); @@ -2969,17 +3048,18 @@ function _start_router() { return; } // set this flag to distinguish between navigations triggered by - // clicking a hash link and those triggered by popstate - hash_navigating = true; + // clicking a hash link and those triggered by popstate. We gotta retrieve + // history metadata here because the hashchange event will occur after history.state was updated + hash_navigating = /** @type {HistoryMetadata} */ (get_history_metadata()); - update_scroll_positions(current_history_index); + capture_scroll(current_history_index); update_url(url); if (!options.replace_state) return; // hashchange event shouldn't occur if the router is replacing state. - hash_navigating = false; + hash_navigating = null; } event.preventDefault(); @@ -3054,18 +3134,26 @@ function _start_router() { addEventListener('popstate', async (event) => { if (resetting_focus) return; - if (event.state?.[HISTORY_INDEX]) { - const history_index = event.state[HISTORY_INDEX]; + const history_metadata = get_history_metadata(event.state); + + // For popstate events we honor keepFocus but not noScroll because you generally expect + // to come back to the scroll position you were at when you left the page. + + if (history_metadata?.historyIndex) { + const history_index = history_metadata.historyIndex; + const source_info = history_info[current_history_index]; navigation_token = invalidation_token = {}; // if a popstate-driven navigation is cancelled, we need to counteract it // with history.go, which means we end up back here, hence this check if (history_index === current_history_index) return; - const scroll = scroll_positions[history_index]; - const state = event.state[STATES_KEY] ?? {}; - const url = new URL(event.state[PAGE_URL_KEY] ?? location.href); - const navigation_index = event.state[NAVIGATION_INDEX]; + const delta = history_index - current_history_index; + const options = delta > 0 ? history_metadata : source_info; + const scroll = history_info[history_index]?.scroll; + const state = history_metadata.state; + const url = new URL(history_metadata.pageUrl ?? location.href); + const navigation_index = history_metadata.navigationIndex; const is_hash_change = current.url && (location.href + current.url.href).includes('#') // check if even has a hash ? strip_hash(location) === strip_hash(current.url) @@ -3073,9 +3161,9 @@ function _start_router() { const shallow = navigation_index === current_navigation_index && ((has_navigated && - (!event.state?.[PAGE_URL_KEY] || event.state[PAGE_URL_KEY] === location.href)) || + (history_metadata.pageUrl === undefined || history_metadata.pageUrl === location.href)) || is_hash_change); - const shallow_url = event.state[PAGE_URL_KEY] ? new URL(location.href) : null; + const shallow_url = history_metadata.pageUrl ? new URL(location.href) : null; const shallow_intent = shallow_url ? await get_navigation_intent(shallow_url, false) : undefined; @@ -3092,6 +3180,9 @@ function _start_router() { // This happens with hash links and `pushState`/`replaceState`. The // exception is if we haven't navigated yet, since we could have // got here after a modal navigation then a reload + + blur_active_element(options.keepFocus); + if (state !== page.state) { page.state = state; } @@ -3100,18 +3191,16 @@ function _start_router() { update_url(url); - scroll_positions[current_history_index] = scroll_state(); - if (scroll) scrollTo(scroll.x, scroll.y); - + capture_scroll(current_history_index); current_history_index = history_index; + if (scroll) scrollTo(scroll.x, scroll.y); return; } - const delta = history_index - current_history_index; - await navigate({ type: 'popstate', url, + keepfocus: options.keepFocus, popped: { state, scroll, @@ -3149,16 +3238,21 @@ function _start_router() { // if the hashchange happened as a result of clicking on a link, // we need to update history, otherwise we have to leave it alone if (hash_navigating) { - hash_navigating = false; + const history_metadata = hash_navigating; + hash_navigating = null; history.replaceState( { ...history.state, - [HISTORY_INDEX]: ++current_history_index, - [NAVIGATION_INDEX]: current_navigation_index + [HISTORY_METADATA_KEY]: { + ...history_metadata, + historyIndex: ++current_history_index, + navigationIndex: current_navigation_index + } }, '', location.href ); + set_history_options(current_history_index, history_metadata); } }); @@ -3299,9 +3393,8 @@ async function _hydrate( if (!result) return; if (result.props.page) { - result.props.page.state = history.state?.[STATES_PERSISTED_KEY] - ? (history.state[STATES_KEY] ?? {}) - : {}; + const history_metadata = get_history_metadata(); + result.props.page.state = history_metadata?.persistState ? history_metadata.state : {}; } await initialize(result, target, hydrate); diff --git a/packages/kit/src/runtime/client/constants.js b/packages/kit/src/runtime/client/constants.js index dc8e87e2523a..3ea23144d125 100644 --- a/packages/kit/src/runtime/client/constants.js +++ b/packages/kit/src/runtime/client/constants.js @@ -1,11 +1,6 @@ export const SNAPSHOT_KEY = 'sveltekit:snapshot'; -export const SCROLL_KEY = 'sveltekit:scroll'; -export const STATES_KEY = 'sveltekit:states'; -export const STATES_PERSISTED_KEY = 'sveltekit:states-persisted'; -export const PAGE_URL_KEY = 'sveltekit:pageurl'; - -export const HISTORY_INDEX = 'sveltekit:history'; -export const NAVIGATION_INDEX = 'sveltekit:navigation'; +export const HISTORY_INFO_KEY = 'sveltekit:history-info'; +export const HISTORY_METADATA_KEY = 'sveltekit:metadata'; export const PRELOAD_PRIORITIES = /** @type {const} */ ({ tap: 1, diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte index 9c586d110847..55126cb2496b 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte @@ -68,6 +68,17 @@ >end shallow +
+ + + +

active: {page.state.active ?? false}

@@ -77,3 +88,4 @@ {resolved} {data.now} +
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index c09eaf977454..b1851f4e4fec 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1952,6 +1952,51 @@ test.describe('Shallow routing', () => { await expect(page.locator('p')).toHaveText('active: true'); }); + test('Preserves shallow navigation options across history entries', async ({ page }) => { + await page.goto('/shallow-routing/push-state'); + const input = page.locator('[data-id="options-focus"]'); + const defaults = page.locator('[data-id="options-default"]'); + const disabled = page.locator('[data-id="options-false"]'); + + await input.focus(); + await page.evaluate(() => window.scrollTo(0, 500)); + await defaults.click(); + + await expect(page).toHaveURL(/\?options=default$/); + await expect + .poll(() => + page.evaluate(() => ({ + focus: document.activeElement?.getAttribute('data-id'), + y: scrollY + })) + ) + .toEqual({ focus: 'options-default', y: 500 }); + + await disabled.click(); + + await expect(page).toHaveURL(/\?options=false$/); + await expect + .poll(() => page.evaluate(() => ({ focus: document.activeElement?.tagName, y: scrollY }))) + .toEqual({ focus: 'BODY', y: 0 }); + + await input.focus(); + await page.evaluate(() => window.scrollTo(0, 700)); + await page.goBack(); + await expect(page).toHaveURL(/\?options=default$/); + await expect + .poll(() => page.evaluate(() => ({ focus: document.activeElement?.tagName, y: scrollY }))) + .toEqual({ focus: 'BODY', y: 500 }); + + await input.focus(); + await page.evaluate(() => window.scrollTo(0, 300)); + + await page.goForward(); + await expect(page).toHaveURL(/\?options=false$/); + await expect + .poll(() => page.evaluate(() => ({ focus: document.activeElement?.tagName, y: scrollY }))) + .toEqual({ focus: 'BODY', y: 700 }); + }); + test('Replaces state on the current URL', async ({ baseURL, page, clicknav }) => { await page.goto('/shallow-routing/replace-state/b'); await clicknav('[href="/shallow-routing/replace-state"]'); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index b9d501ef7784..d796af78f003 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1250,12 +1250,12 @@ declare module '@sveltejs/kit' { shallow?: boolean; /** * If `true`, preserves the browser's scroll position. - * @default false + * @default false, or true when `shallow` is true */ noScroll?: boolean; /** * If `true`, keeps the currently focused element focused. - * @default false + * @default false, or true when `shallow` is true */ keepFocus?: boolean; /** @@ -3285,17 +3285,13 @@ declare module '$app/navigation' { * * @deprecated Use `goto(url, { state, shallow: true })` instead. * */ - export function pushState(url: string | URL, state: App.PageState, options?: { - persist?: boolean | undefined; - }): Promise; + export function pushState(url: string | URL, state: App.PageState): Promise; /** * Programmatically replace the current history entry with the given `page.state`. Used for [shallow routing](https://svelte.dev/docs/kit/shallow-routing). * * @deprecated Use `goto(url, { state, shallow: true, replace: true })` instead. * */ - export function replaceState(url: string | URL, state: App.PageState, options?: { - persist?: boolean | undefined; - }): Promise; + export function replaceState(url: string | URL, state: App.PageState): Promise; type MaybePromise = T | Promise; export {}; From d33a324df5db9cf7f08468c708d706041ba8c9db Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 24 Jul 2026 23:43:22 +0200 Subject: [PATCH 16/29] handle noscroll/keepfocus in popstate better --- packages/kit/src/runtime/client/client.js | 75 +++++++++++++------ .../kit/test/apps/basics/test/client.test.js | 61 +++++++++++++++ 2 files changed, 113 insertions(+), 23 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 9977006bf0b4..c14b111fcb49 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -57,7 +57,8 @@ const Root = asClassComponent(RootModern); * pageUrl?: string; * state: Record; * persistState: boolean; - * keepFocus: boolean; + * noScrollIndex: number; + * keepFocusIndex: number; * }} HistoryMetadata */ @@ -90,7 +91,7 @@ const resetters = []; // popstate it's too late to access the options or update the focus position associated with the // state we're navigating from /** - * @type {Record} + * @type {Record} */ const history_info = storage.get(HISTORY_INFO_KEY) ?? {}; @@ -154,12 +155,13 @@ function capture_scroll(index) { /** * @param {number} index - * @param {Pick} options + * @param {Pick} options */ function set_history_options(index, options) { history_info[index] = { ...history_info[index], - keepFocus: options.keepFocus + noScrollIndex: options.noScrollIndex, + keepFocusIndex: options.keepFocusIndex }; } @@ -360,6 +362,12 @@ let current_history_index; /** @type {number} */ let current_navigation_index; +/** @type {number} */ +let current_noscroll_index; + +/** @type {number} */ +let current_keepfocus_index; + /** * @param {any} [state] * @returns {HistoryMetadata | undefined} @@ -470,11 +478,17 @@ export async function start(_app, _target, hydrate) { const history_metadata = get_history_metadata(); current_history_index = history_metadata?.historyIndex ?? 0; current_navigation_index = history_metadata?.navigationIndex ?? 0; + current_noscroll_index = history_metadata?.noScrollIndex ?? 0; + current_keepfocus_index = history_metadata?.keepFocusIndex ?? 0; if (!current_history_index) { // we use Date.now() as an offset so that cross-document navigations // within the app don't result in data loss - current_history_index = current_navigation_index = Date.now(); + current_history_index = + current_navigation_index = + current_noscroll_index = + current_keepfocus_index = + Date.now(); // create initial history entry, so we can return here history.replaceState( @@ -485,8 +499,8 @@ export async function start(_app, _target, hydrate) { navigationIndex: current_navigation_index, state: {}, persistState: false, - noScroll: false, - keepFocus: false + noScrollIndex: current_history_index, + keepFocusIndex: current_history_index } }, '' @@ -1971,6 +1985,10 @@ async function navigate({ if (!popped) { // this is a new navigation, rather than a popstate const change = replace_state ? 0 : 1; + if (type !== 'enter') { + if (!noscroll) current_noscroll_index += 1; + if (!keepfocus) current_keepfocus_index += 1; + } const entry = { [HISTORY_METADATA_KEY]: /** @satisfies {HistoryMetadata} */ ({ @@ -1978,7 +1996,8 @@ async function navigate({ navigationIndex: (current_navigation_index += change), state, persistState: persist_state, - keepFocus: keepfocus ?? false + noScrollIndex: current_noscroll_index, + keepFocusIndex: current_keepfocus_index }) }; @@ -2110,12 +2129,7 @@ async function navigate({ Object.assign(navigation_result.props.page, rendering_error); } - reset_scroll_and_focus( - url, - popped ? popped.scroll : noscroll ? scroll_state() : null, - keepfocus, - activeElement - ); + reset_scroll_and_focus(url, noscroll ? scroll_state() : popped?.scroll, keepfocus, activeElement); is_navigating = false; @@ -2787,6 +2801,8 @@ async function update_state(url, state, { replace, persist_state, noscroll, keep } if (!replace) capture_scroll(current_history_index); + if (!noscroll) current_noscroll_index += 1; + if (!keepfocus) current_keepfocus_index += 1; const entry = { [HISTORY_METADATA_KEY]: /** @satisfies {HistoryMetadata} */ ({ @@ -2795,7 +2811,8 @@ async function update_state(url, state, { replace, persist_state, noscroll, keep pageUrl: page.url.href, state, persistState: persist_state, - keepFocus: keepfocus + noScrollIndex: current_noscroll_index, + keepFocusIndex: current_keepfocus_index }) }; @@ -3069,7 +3086,11 @@ function _start_router() { // set this flag to distinguish between navigations triggered by // clicking a hash link and those triggered by popstate. We gotta retrieve // history metadata here because the hashchange event will occur after history.state was updated - hash_navigating = /** @type {HistoryMetadata} */ (get_history_metadata()); + hash_navigating = { + .../** @type {HistoryMetadata} */ (get_history_metadata()), + noScrollIndex: current_noscroll_index + (options.noscroll ? 0 : 1), + keepFocusIndex: current_keepfocus_index + (options.keepfocus ? 0 : 1) + }; capture_scroll(current_history_index); @@ -3155,9 +3176,6 @@ function _start_router() { const history_metadata = get_history_metadata(event.state); - // For popstate events we honor keepFocus but not noScroll because you generally expect - // to come back to the scroll position you were at when you left the page. - if (history_metadata?.historyIndex) { const history_index = history_metadata.historyIndex; const source_info = history_info[current_history_index]; @@ -3168,7 +3186,11 @@ function _start_router() { if (history_index === current_history_index) return; const delta = history_index - current_history_index; - const options = delta > 0 ? history_metadata : source_info; + const no_scroll_index = history_metadata.noScrollIndex; + const keep_focus_index = history_metadata.keepFocusIndex; + const noscroll = no_scroll_index === (source_info?.noScrollIndex ?? current_noscroll_index); + const keepfocus = + keep_focus_index === (source_info?.keepFocusIndex ?? current_keepfocus_index); const scroll = history_info[history_index]?.scroll; const state = history_metadata.state; const url = new URL(history_metadata.pageUrl ?? location.href); @@ -3200,7 +3222,7 @@ function _start_router() { // exception is if we haven't navigated yet, since we could have // got here after a modal navigation then a reload - blur_active_element(options.keepFocus); + blur_active_element(keepfocus); if (state !== page.state) { page.state = state; @@ -3212,14 +3234,17 @@ function _start_router() { capture_scroll(current_history_index); current_history_index = history_index; - if (scroll) scrollTo(scroll.x, scroll.y); + current_noscroll_index = no_scroll_index; + current_keepfocus_index = keep_focus_index; + if (!noscroll && scroll) scrollTo(scroll.x, scroll.y); return; } await navigate({ type: 'popstate', url, - keepfocus: options.keepFocus, + keepfocus, + noscroll, popped: { state, scroll, @@ -3229,6 +3254,8 @@ function _start_router() { accept: () => { current_history_index = history_index; current_navigation_index = navigation_index; + current_noscroll_index = no_scroll_index; + current_keepfocus_index = keep_focus_index; }, block: () => { history.go(-delta); @@ -3259,6 +3286,8 @@ function _start_router() { if (hash_navigating) { const history_metadata = hash_navigating; hash_navigating = null; + current_noscroll_index = history_metadata.noScrollIndex; + current_keepfocus_index = history_metadata.keepFocusIndex; history.replaceState( { ...history.state, diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index b1851f4e4fec..3639f9cf6f40 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1997,6 +1997,67 @@ test.describe('Shallow routing', () => { .toEqual({ focus: 'BODY', y: 700 }); }); + test('Preserves scroll and focus across popstate unless an intervening navigation resets them', async ({ + app, + page + }) => { + for (const shallow of [true, false]) { + const prefix = shallow ? 'shallow' : 'regular'; + const input = page.locator('[data-id="options-focus"]'); + const state = () => + page.evaluate(() => ({ + focus: document.activeElement?.getAttribute('data-id') ?? document.activeElement?.tagName, + y: scrollY + })); + + await page.goto('/shallow-routing/push-state'); + await input.focus(); + await page.evaluate(() => scrollTo(0, 500)); + + await app.goto(`?${prefix}=a`, { shallow, noScroll: true, keepFocus: true }); + await expect.poll(state).toEqual({ focus: 'options-focus', y: 500 }); + + await page.evaluate(() => scrollTo(0, 700)); + await page.goBack(); + await expect(page).not.toHaveURL(new RegExp(`${prefix}=a`)); + await expect.poll(state).toEqual({ focus: 'options-focus', y: 700 }); + + await page.evaluate(() => scrollTo(0, 300)); + await page.goForward(); + await expect(page).toHaveURL(new RegExp(`${prefix}=a`)); + await expect.poll(state).toEqual({ focus: 'options-focus', y: 300 }); + + await page.reload(); + await input.focus(); + await page.evaluate(() => scrollTo(0, 350)); + await page.goBack(); + await expect(page).not.toHaveURL(new RegExp(`${prefix}=a`)); + await expect.poll(state).toEqual({ focus: 'options-focus', y: 350 }); + + await page.evaluate(() => scrollTo(0, 400)); + await page.goForward(); + await expect(page).toHaveURL(new RegExp(`${prefix}=a`)); + await expect.poll(state).toEqual({ focus: 'options-focus', y: 400 }); + + await app.goto(`?${prefix}=b`, { shallow, noScroll: false, keepFocus: false }); + await input.focus(); + await page.evaluate(() => scrollTo(0, 700)); + await app.goto(`?${prefix}=c`, { shallow, noScroll: true, keepFocus: true }); + await expect.poll(state).toEqual({ focus: 'options-focus', y: 700 }); + + await page.evaluate(() => scrollTo(0, 900)); + await page.evaluate(() => history.go(-2)); + await expect(page).toHaveURL(new RegExp(`${prefix}=a`)); + await expect.poll(state).toEqual({ focus: 'BODY', y: 400 }); + + await input.focus(); + await page.evaluate(() => scrollTo(0, 400)); + await page.evaluate(() => history.go(2)); + await expect(page).toHaveURL(new RegExp(`${prefix}=c`)); + await expect.poll(state).toEqual({ focus: 'BODY', y: 900 }); + } + }); + test('Replaces state on the current URL', async ({ baseURL, page, clicknav }) => { await page.goto('/shallow-routing/replace-state/b'); await clicknav('[href="/shallow-routing/replace-state"]'); From 9e649868cb1b367226498d13bc26f360ed763ef5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Jul 2026 23:04:40 -0400 Subject: [PATCH 17/29] tweak --- packages/kit/src/runtime/client/client.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 6302dea294d0..539567779c75 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2450,7 +2450,9 @@ let warned_on_push_state = false; let warned_on_replace_state_function = false; /** - * Allows you to navigate programmatically to a given route, with options such as keeping the current element focused. Pass `null` to update `page.state` without changing the URL. + * Allows you to navigate programmatically to a given route, with control over details such as whether scroll and focus are reset + * (as they would be with a regular navigation) or preserved. + * * Returns a Promise that resolves when SvelteKit navigates (or fails to navigate, in which case the promise rejects) or the state change has been applied. * * Unless `shallow` is `true`, `goto` is intended for navigations to routes that belong to the app. From 372bccbb49c1e4a71ef229e6c2298e400ae9f245 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Jul 2026 23:15:07 -0400 Subject: [PATCH 18/29] =?UTF-8?q?disallow=20shallow=20routing=20to=20exter?= =?UTF-8?q?nal=20pathnames=20=E2=80=94=20i=20don't=20see=20any=20good=20re?= =?UTF-8?q?ason=20to=20allow=20this,=20it=20will=20just=20break=20on=20rel?= =?UTF-8?q?oad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/kit/src/runtime/client/client.js | 36 +++++++++++++---------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 539567779c75..392ed9c0f78a 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2455,8 +2455,7 @@ let warned_on_replace_state_function = false; * * Returns a Promise that resolves when SvelteKit navigates (or fails to navigate, in which case the promise rejects) or the state change has been applied. * - * Unless `shallow` is `true`, `goto` is intended for navigations to routes that belong to the app. - * If the URL does not resolve to a route within the app, the returned promise will reject unless `shallow` is `true`. + * `goto` is intended for navigations to routes that belong to the app, and will reject if a route cannot be resolved. * For external URLs, use `window.location = url` to perform a full-page navigation instead of calling `goto(url)`. * * @param {string | URL} url Where to navigate to. Note that if you've set [`config.paths.base`](https://svelte.dev/docs/kit/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app. @@ -2487,6 +2486,16 @@ export async function goto(url, opts = {}) { ); } + const intent = await get_navigation_intent(url, false); + + if (!intent) { + throw new Error( + DEV + ? `Cannot use \`goto\` with a URL that does not resolve to a route within the app. Use \`window.location = "${url}"\` instead` + : 'goto: invalid URL' + ); + } + if (opts.shallow) { // Untrack to avoid triggering outer reactive contexts because we access page.X inside return untrack(() => @@ -2497,23 +2506,14 @@ export async function goto(url, opts = {}) { replace, persist_state: opts.persistState ?? false, noscroll: opts.noScroll ?? true, - keepfocus: opts.keepFocus ?? true + keepfocus: opts.keepFocus ?? true, + intent }, 'goto' ) ); } - const intent = await get_navigation_intent(url, false); - - if (!intent) { - throw new Error( - DEV - ? `Cannot use \`goto\` with a URL that does not resolve to a route within the app. Use \`window.location = "${url}"\` instead` - : 'goto: invalid URL' - ); - } - if (DEV && 'invalidateAll' in opts && !warned_on_invalidate_all) { warned_on_invalidate_all = true; console.warn( @@ -2765,10 +2765,15 @@ export async function replaceState(url, state) { /** * @param {string | URL} url * @param {App.PageState} state - * @param {{ replace: boolean; persist_state: boolean; noscroll: boolean; keepfocus: boolean }} options + * @param {{ replace: boolean; persist_state: boolean; noscroll: boolean; keepfocus: boolean; intent?: NavigationIntent }} options * @param {'goto' | 'pushState' | 'replaceState'} caller */ -async function update_state(url, state, { replace, persist_state, noscroll, keepfocus }, caller) { +async function update_state( + url, + state, + { replace, persist_state, noscroll, keepfocus, intent }, + caller +) { if (DEV) { if (!started) { throw new Error(`Cannot call ${caller}(...) before router is initialized`); @@ -2784,7 +2789,6 @@ async function update_state(url, state, { replace, persist_state, noscroll, keep } const resolved = resolve_url(url); - const intent = await get_navigation_intent(resolved, false); const nav = // For backwards compatibility we don't trigger navigation hooks etc for push/replaceState caller === 'goto' From 7f93468efd84fbe6ae4bf2386b65e8a3a8d38f7c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Jul 2026 23:19:39 -0400 Subject: [PATCH 19/29] hopefully fix docs --- documentation/docs/30-advanced/67-shallow-routing.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index 281636f90e53..a8109e961d20 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -40,6 +40,8 @@ The modal can be dismissed by navigating back (unsetting `page.state.showModal`) You can also update the visible URL during a shallow navigation: ```js +import { goto } from '$app/navigation'; +// ---cut--- goto('/photos/1', { shallow: true, state: { showModal: true } @@ -70,18 +72,22 @@ A regular `goto` call without `shallow: true`, or a standard link click, exits s ## Routing options -By default, the above examples create a new navigation entry in the history stack. If you don't want that, you can replace the existing navigation entry instead: +By default, the above examples create a new navigation entry in the history stack. If you don't want that, you can replace the existing navigation entry instead: ```js +import { goto } from '$app/navigation'; +// ---cut--- goto(url, { state, replace: true }); ``` -By default, state set with `goto` is not restored after a reload. To change that, use `persistState`: +By default, state set with `goto` is not restored after a reload. To change that, use `persistState`: ```js +import { goto } from '$app/navigation'; +// ---cut--- goto(url, { state, persistState: true @@ -91,6 +97,8 @@ goto(url, { Shallow navigations preserve the current scroll position and focused element by default. You can opt out of either behavior with `noScroll: false` or `keepFocus: false`: ```js +import { goto } from '$app/navigation'; +// ---cut--- goto(url, { shallow: true, noScroll: false, From 95557e748e6e8fc6b3fc366eff1cf5ec623e0566 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Jul 2026 23:32:13 -0400 Subject: [PATCH 20/29] tweaks --- documentation/docs/30-advanced/67-shallow-routing.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index a8109e961d20..daf3f37d06f5 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -30,13 +30,12 @@ SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, {/if} ``` -Because the navigation doesn't run `load` functions or switch to a different `+page.svelte`, we call this **shallow routing**. +Because the navigation doesn't run `load` functions, switch to a different `+page.svelte`, or (by default) reset scroll and focus, we call this _shallow routing_. State can be accessed through the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). The modal can be dismissed by navigating back (unsetting `page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically. - You can also update the visible URL during a shallow navigation: ```js @@ -50,7 +49,7 @@ goto('/photos/1', { Regardless of whether you choose to update the visible URL or not, [`beforeNavigate`]($app-navigation#beforeNavigate), [`onNavigate`]($app-navigation#onNavigate) and [`afterNavigate`]($app-navigation#afterNavigate) will run with `navigation.type === 'goto'` and `navigation.shallow === true`. -Once shallow routing is active, `page.shallow` is set with the visible URL, parameters and route id. `page.url`, `page.params` and `page.route` continue to describe the page that was last rendered as a result of an actual navigation. +Once shallow routing is active, `page.shallow` becomes a `{ url, params, route }` object describing the page that _would_ be rendered if the user were to navigate there (which would happen if, for example, they reloaded the page). `page.url`, `page.params` and `page.route` continue to describe the page that is currently rendered. ```svelte From e961515146473850f996808205c90b1d54fcf359 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Jul 2026 23:32:46 -0400 Subject: [PATCH 21/29] more fixes --- documentation/docs/30-advanced/67-shallow-routing.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index daf3f37d06f5..31fe6ade0252 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -75,6 +75,7 @@ By default, the above examples create a new navigation entry in the history stac ```js import { goto } from '$app/navigation'; +const url = new URL('https://example.com'); // ---cut--- goto(url, { state, @@ -86,6 +87,7 @@ By default, state set with `goto` is not restored after a reload. To change that ```js import { goto } from '$app/navigation'; +const url = new URL('https://example.com'); // ---cut--- goto(url, { state, @@ -97,6 +99,7 @@ Shallow navigations preserve the current scroll position and focused element by ```js import { goto } from '$app/navigation'; +const url = new URL('https://example.com'); // ---cut--- goto(url, { shallow: true, From a12b1c009ede5b7d327664893204d8e36e6531c2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Jul 2026 23:48:06 -0400 Subject: [PATCH 22/29] apply same internal route restriction to pushState/replaceState --- packages/kit/src/runtime/client/client.js | 106 ++++++++++++---------- 1 file changed, 56 insertions(+), 50 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 392ed9c0f78a..d2c141025563 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2449,6 +2449,34 @@ let warned_on_replace_state = false; let warned_on_push_state = false; let warned_on_replace_state_function = false; +/** + * @param {string | URL} url + * @param {'goto' | 'pushState' | 'replaceState'} caller + */ +async function resolve_intent(url, caller) { + const resolved = new URL(resolve_url(url)); + + if (resolved.origin !== origin) { + throw new Error( + DEV + ? `Cannot use \`${caller}\` with an external URL. Use \`window.location = "${url}"\` instead` + : `${caller}: invalid URL` + ); + } + + const intent = await get_navigation_intent(resolved, false); + + if (!intent) { + throw new Error( + DEV + ? `Cannot use \`${caller}\` with a URL that does not resolve to a route within the app. Use \`window.location = "${url}"\` instead` + : `${caller}: invalid URL` + ); + } + + return intent; +} + /** * Allows you to navigate programmatically to a given route, with control over details such as whether scroll and focus are reset * (as they would be with a regular navigation) or preserved. @@ -2476,38 +2504,19 @@ export async function goto(url, opts = {}) { const replace = opts.replace ?? opts.replaceState ?? false; - url = new URL(resolve_url(url)); - - if (url.origin !== origin) { - throw new Error( - DEV - ? `Cannot use \`goto\` with an external URL. Use \`window.location = "${url}"\` instead` - : 'goto: invalid URL' - ); - } - - const intent = await get_navigation_intent(url, false); - - if (!intent) { - throw new Error( - DEV - ? `Cannot use \`goto\` with a URL that does not resolve to a route within the app. Use \`window.location = "${url}"\` instead` - : 'goto: invalid URL' - ); - } + const intent = await resolve_intent(url, 'goto'); if (opts.shallow) { // Untrack to avoid triggering outer reactive contexts because we access page.X inside return untrack(() => update_state( - url, + intent, opts.state ?? {}, { replace, persist_state: opts.persistState ?? false, noscroll: opts.noScroll ?? true, - keepfocus: opts.keepFocus ?? true, - intent + keepfocus: opts.keepFocus ?? true }, 'goto' ) @@ -2522,7 +2531,7 @@ export async function goto(url, opts = {}) { } return _goto( - url, + intent.url, { ...opts, replace, refreshAll: opts.refreshAll ?? opts.invalidateAll }, 0, {}, @@ -2720,14 +2729,13 @@ export async function pushState(url, state) { ); } - // Untrack to avoid triggering outer reactive contexts because we access page.X inside - await untrack(() => - update_state( - url, - state, - { replace: false, persist_state: false, noscroll: true, keepfocus: true }, - 'pushState' - ) + const intent = await resolve_intent(url, 'pushState'); + + await update_state( + intent, + state, + { replace: false, persist_state: false, noscroll: true, keepfocus: true }, + 'pushState' ); } @@ -2751,29 +2759,30 @@ export async function replaceState(url, state) { ); } - // Untrack to avoid triggering outer reactive contexts because we access page.X inside - await untrack(() => - update_state( - url, - state, - { replace: true, persist_state: false, noscroll: true, keepfocus: true }, - 'replaceState' - ) + const intent = await resolve_intent(url, 'replaceState'); + + await update_state( + intent, + state, + { replace: true, persist_state: false, noscroll: true, keepfocus: true }, + 'replaceState' ); } /** - * @param {string | URL} url + * @param {NavigationIntent} intent * @param {App.PageState} state - * @param {{ replace: boolean; persist_state: boolean; noscroll: boolean; keepfocus: boolean; intent?: NavigationIntent }} options + * @param {{ replace: boolean; persist_state: boolean; noscroll: boolean; keepfocus: boolean; }} options * @param {'goto' | 'pushState' | 'replaceState'} caller */ async function update_state( - url, + intent, state, - { replace, persist_state, noscroll, keepfocus, intent }, + { replace, persist_state, noscroll, keepfocus }, caller ) { + const url = intent.url; + if (DEV) { if (!started) { throw new Error(`Cannot call ${caller}(...) before router is initialized`); @@ -2788,12 +2797,9 @@ async function update_state( } } - const resolved = resolve_url(url); const nav = // For backwards compatibility we don't trigger navigation hooks etc for push/replaceState - caller === 'goto' - ? _before_navigate({ url: resolved, type: 'goto', intent, shallow: true }) - : undefined; + caller === 'goto' ? _before_navigate({ url, type: 'goto', intent, shallow: true }) : undefined; if (!nav && caller === 'goto') return; @@ -2823,7 +2829,7 @@ async function update_state( }; const fn = replace ? history.replaceState : history.pushState; - fn.call(history, entry, '', resolved); + fn.call(history, entry, '', url); set_history_options(current_history_index, entry[HISTORY_METADATA_KEY]); if (!replace) { @@ -2857,7 +2863,7 @@ async function update_state( page.shallow = { params: intent?.params ?? null, route: intent ? { id: intent.route.id } : null, - url: resolved + url }; if (!nav) return; @@ -2872,7 +2878,7 @@ async function update_state( return; } - reset_scroll_and_focus(resolved, noscroll ? scroll_state() : null, keepfocus, activeElement); + reset_scroll_and_focus(url, noscroll ? scroll_state() : null, keepfocus, activeElement); is_navigating = false; nav.fulfil(undefined); From e2bb97e607c7e95c9200b093404364a94390ab1f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Jul 2026 23:49:04 -0400 Subject: [PATCH 23/29] no need for untrack, call is after an await --- packages/kit/src/runtime/client/client.js | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index d2c141025563..034d52c538fd 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2507,19 +2507,16 @@ export async function goto(url, opts = {}) { const intent = await resolve_intent(url, 'goto'); if (opts.shallow) { - // Untrack to avoid triggering outer reactive contexts because we access page.X inside - return untrack(() => - update_state( - intent, - opts.state ?? {}, - { - replace, - persist_state: opts.persistState ?? false, - noscroll: opts.noScroll ?? true, - keepfocus: opts.keepFocus ?? true - }, - 'goto' - ) + return update_state( + intent, + opts.state ?? {}, + { + replace, + persist_state: opts.persistState ?? false, + noscroll: opts.noScroll ?? true, + keepfocus: opts.keepFocus ?? true + }, + 'goto' ); } From ef4d65f3bc253ac0964aa810a15aae4c4f5fa7dd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Jul 2026 23:50:14 -0400 Subject: [PATCH 24/29] try again --- documentation/docs/30-advanced/67-shallow-routing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index 31fe6ade0252..960d11e08105 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -76,6 +76,7 @@ By default, the above examples create a new navigation entry in the history stac ```js import { goto } from '$app/navigation'; const url = new URL('https://example.com'); +const state = { showModal: true }; // ---cut--- goto(url, { state, @@ -88,6 +89,7 @@ By default, state set with `goto` is not restored after a reload. To change that ```js import { goto } from '$app/navigation'; const url = new URL('https://example.com'); +const state = { showModal: true }; // ---cut--- goto(url, { state, From d0ef3a1abcacc1a911cce22e8b5c3fd8144024fc Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 25 Jul 2026 00:24:16 -0400 Subject: [PATCH 25/29] tweak docs --- .../docs/30-advanced/67-shallow-routing.md | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index 960d11e08105..298a885c090b 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -2,11 +2,13 @@ title: Page state & shallow routing --- -As you navigate around a SvelteKit app, you create _history entries_. Clicking the back and forward buttons traverses through this list of entries, re-running any `load` functions and replacing page components as necessary. +As you navigate around a SvelteKit app, you create _history entries_. Clicking the back and forward buttons traverses through this list of entries, re-running any `load` functions, replacing page components, and updating the scroll position and focused element as necessary. -Sometimes, it's useful to create history entries _without_ performing a full navigation. For example, you might want to show a modal dialog that the user can dismiss by navigating back. This is particularly valuable on mobile devices, where swipe gestures are often more natural than interacting directly with the UI. In these cases, a modal that is _not_ associated with a history entry can be a source of frustration, as a user may swipe backwards in an attempt to dismiss it and find themselves on the wrong page. +Sometimes, it's useful to create history entries _without_ performing a full navigation. We call this _shallow routing_. -SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, which allows you to associate state with a history entry without navigating by using its `shallow: true` option. For example, to implement a history-driven modal: +For example, you might want to show a modal dialog that the user can dismiss by navigating back. This is particularly valuable on mobile devices, where swipe gestures are often more natural than interacting directly with the UI. In these cases, a modal that is _not_ associated with a history entry can be a source of frustration, as a user may swipe backwards in an attempt to dismiss it and find themselves on the wrong page. + +SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, which allows you to associate state with a history entry without navigating with the `shallow: true` option: ```svelte @@ -30,23 +32,24 @@ SvelteKit makes this possible with the [`goto`]($app-navigation#goto) function, {/if} ``` -Because the navigation doesn't run `load` functions, switch to a different `+page.svelte`, or (by default) reset scroll and focus, we call this _shallow routing_. - -State can be accessed through the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). +State is globally accessible via the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`). The modal can be dismissed by navigating back (unsetting `page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically. -You can also update the visible URL during a shallow navigation: +You can also update the contents of the browser's URL bar during a shallow navigation: ```js import { goto } from '$app/navigation'; +const state = { active: true }; // ---cut--- -goto('/photos/1', { - shallow: true, - state: { showModal: true } +goto('/another/page', { + state, + shallow: true }); ``` +> [!NOTE] If the user reloads, they will land on `/another/page`, _not_ the currently rendered page — see [Caveats](#caveats). + Regardless of whether you choose to update the visible URL or not, [`beforeNavigate`]($app-navigation#beforeNavigate), [`onNavigate`]($app-navigation#onNavigate) and [`afterNavigate`]($app-navigation#afterNavigate) will run with `navigation.type === 'goto'` and `navigation.shallow === true`. Once shallow routing is active, `page.shallow` becomes a `{ url, params, route }` object describing the page that _would_ be rendered if the user were to navigate there (which would happen if, for example, they reloaded the page). `page.url`, `page.params` and `page.route` continue to describe the page that is currently rendered. @@ -66,7 +69,7 @@ Once shallow routing is active, `page.shallow` becomes a `{ url, params, route } A regular `goto` call without `shallow: true`, or a standard link click, exits shallow routing. -> [!NOTE] +> [!LEGACY] > In SvelteKit 2 this functionality was achieved using `pushState` and `replaceState`, which are now deprecated. Use `goto` with `shallow: true` instead, and use the `replace` option when replacing the current history entry. ## Routing options @@ -172,8 +175,6 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c ## Caveats -During server-side rendering, `page.state` is always an empty object. - -Shallow routing is a feature that requires JavaScript to work. Be mindful when using it and try to think of sensible fallback behavior in case JavaScript isn't available. +Shallow routing requires JavaScript. Be mindful when using it and try to think of sensible fallback behaviour in case JavaScript isn't available. -If you navigate to another page via shallow routing, reloading on that route will not start the app in shallow routing mode. Instead, the actual page on that URL is loaded - in the above example, you would land on `src/routes/photos/[id]/+page.svelte` instead of `src/routes/photos/+page.svelte`. On the server, this is unavoidable (because history state isn't available at that point), and hence it would mean too much of a UI flicker if it changed the page and entered shallow routing once JavaScript is loaded. This is regardless of the `persistState` option. +The server has no awareness of history state. As such, `page.state` is an empty object during SSR, and a reload will navigate directly to the prior `page.shallow.url` if it exists. In other words, if `page.shallow.url.pathname` is `/photos/123` before the reload, after the reload `page.url.pathname` will be `/photos/123` and `page.shallow` will be `null`, regardless of the `persistState` option. (This only applies to the initial page load, to avoid a flickering UI when the client app starts.) From 39ed9b315b91776bf4c4a42abd23ab5811c9490b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 25 Jul 2026 00:24:35 -0400 Subject: [PATCH 26/29] drive-by --- documentation/docs/99-legacy-reference/10-$service-worker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/99-legacy-reference/10-$service-worker.md b/documentation/docs/99-legacy-reference/10-$service-worker.md index 5902a19b6f41..ab638b18c5a8 100644 --- a/documentation/docs/99-legacy-reference/10-$service-worker.md +++ b/documentation/docs/99-legacy-reference/10-$service-worker.md @@ -23,4 +23,4 @@ A `string[]` array of prerendered pages. Empty during dev. Use [`prerendered`]($ ## version -The value of [`config.version.name`](configuration#version), used for populating caches. Use [`version`]($app/env#version) from `$app/env` instead. +The value of [`config.version.name`](configuration#version), used for populating caches. Use [`version`]($app-env#version) from `$app/env` instead. From d41809b7736e84fe47f92b0b06dc81a90a55c511 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 25 Jul 2026 00:29:19 -0400 Subject: [PATCH 27/29] regenerate --- packages/kit/types/index.d.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index c9fc05696a57..b491ed6370ca 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -3180,11 +3180,12 @@ declare module '$app/navigation' { * */ export function disableScrollHandling(): void; /** - * Allows you to navigate programmatically to a given route, with options such as keeping the current element focused. Pass `null` to update `page.state` without changing the URL. + * Allows you to navigate programmatically to a given route, with control over details such as whether scroll and focus are reset + * (as they would be with a regular navigation) or preserved. + * * Returns a Promise that resolves when SvelteKit navigates (or fails to navigate, in which case the promise rejects) or the state change has been applied. * - * Unless `shallow` is `true`, `goto` is intended for navigations to routes that belong to the app. - * If the URL does not resolve to a route within the app, the returned promise will reject unless `shallow` is `true`. + * `goto` is intended for navigations to routes that belong to the app, and will reject if a route cannot be resolved. * For external URLs, use `window.location = url` to perform a full-page navigation instead of calling `goto(url)`. * * @param url Where to navigate to. Note that if you've set [`config.paths.base`](https://svelte.dev/docs/kit/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app. From fb7f126a53becc4fb5e3e70b7465f97039f7fac4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 25 Jul 2026 00:30:36 -0400 Subject: [PATCH 28/29] gah come on --- documentation/docs/30-advanced/67-shallow-routing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md index 298a885c090b..a8180fcc7e94 100644 --- a/documentation/docs/30-advanced/67-shallow-routing.md +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -48,7 +48,7 @@ goto('/another/page', { }); ``` -> [!NOTE] If the user reloads, they will land on `/another/page`, _not_ the currently rendered page — see [Caveats](#caveats). +> [!NOTE] If the user reloads, they will land on `/another/page`, _not_ the currently rendered page — see [Caveats](#Caveats). Regardless of whether you choose to update the visible URL or not, [`beforeNavigate`]($app-navigation#beforeNavigate), [`onNavigate`]($app-navigation#onNavigate) and [`afterNavigate`]($app-navigation#afterNavigate) will run with `navigation.type === 'goto'` and `navigation.shallow === true`. From db68cbd1287bc3e05425d4d9e7e147a2e871827b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 25 Jul 2026 08:00:34 -0400 Subject: [PATCH 29/29] fix --- packages/kit/src/runtime/client/client.js | 2 +- packages/kit/test/apps/basics/test/client.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 034d52c538fd..8148d4d55470 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -6,7 +6,7 @@ /** @import { Query } from './remote-functions/query/instance.svelte.js' */ /** @import { LiveQuery } from './remote-functions/query-live/instance.svelte.js' */ import { BROWSER, DEV } from 'esm-env'; -import { settled, tick, fork, onMount, untrack } from 'svelte'; +import { settled, tick, fork, onMount } from 'svelte'; import { HttpError, Redirect, SvelteKitError } from '@sveltejs/kit/internal'; import { decode_pathname, strip_hash, make_trackable, normalize_path } from '../../utils/url.js'; import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js'; diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 630976e7d1d7..f933ffcc4902 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1489,7 +1489,7 @@ test.describe('goto', () => { await page.click('button'); const message = process.env.DEV - ? 'Cannot use `goto` with an external URL. Use `window.location = "https://example.com/"` instead' + ? 'Cannot use `goto` with an external URL. Use `window.location = "https://example.com"` instead' : 'goto: invalid URL'; await expect(page.locator('p')).toHaveText(message); });