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

Filter by extension

Filter by extension

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

breaking: adjust how `pushState/replaceState` behave
5 changes: 5 additions & 0 deletions .changeset/late-lands-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: provide possibility to preserve state across reloads when using `pushState/replaceState`

@teemingc teemingc Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we were going to recommend using snapshot instead of this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can have both — someone could (for example) open a modal that they want to recreate on reload, and snapshots aren't really built for that

26 changes: 20 additions & 6 deletions documentation/docs/30-advanced/67-shallow-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
}
Expand All @@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
Once shallow routing is active, `page.shallow` is set with the `url`, `params` and `route` that would apply to a normal navigation. `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`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm I'm in two minds about whether it's desirable to skip lifecycle functions for ''. Feels confusing. Would we also skip them in the page.url case? I think we should probably call the lifecycle functions and let people filter them out with to.href !== from.href

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be really annoyed as a user if that was the case. The 99% use case for pushState('', ...) is to set some state, and navigation hooks would just be noise. And if you are not in shallow routing mode already, you're not even entering it, so it also doesn't make sense to fire the navigation hooks.


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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels feels duplicative with the paragraph above (https://github.com/sveltejs/kit/pull/16389/files#r3616836269). I would probably lose that one and keep this one


`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`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these two things are unconnected, we should probably explain the push/replace distinction earlier


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

Expand Down Expand Up @@ -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);
Expand All @@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
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 used.


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.
25 changes: 23 additions & 2 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need some way to identify shallow navigations in popstate events. Otherwise you can't really filter them out — type === 'shallow' is insufficient. I wonder if it needs to be shallow: true rather than type === 'shallow'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in fact I'm almost wondering if we should revisit the decision to keep pushState and replaceState distinct from goto. IIRC we felt it was best because goto is async and fires lifecycle functions whereas pushState/replaceState were sync and didn't. But if that's no longer true then does it still make sense to keep them separate?

await goto('', {
  state: {...},
  shallow: true
});

We even have a replaceState option ready to use. invalidate/invalidateAll/refreshAll can still work, they'd just be generally pointless (and harmless) in this context.

keepFocus is an odd one — we don't want to reset the focus after a shallow navigation, but defaulting to true would kind of imply 'don't allow the newly opened modal to trap focus'. I think this is a sign that we should rename noScroll: true and keepFocus: true to resetScroll: false and resetFocus: false. Using the same verb in both cases feels nicer than having an awkward modifier in one case and a passive verb in the other.

Yeah, the more I think about it the more I think this is the right move.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly would I be doing with that info? i.e. what's the use case of boolean vs type?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boolean:

// works for the initial shallow navigation, and a backwards navigation
if (navigation.shallow) return;

type:

// works for the initial shallow navigation, BUT NOT a backwards navigation
if (navigation.type === 'shallow') return;

If there's something I need to happen or not happen during a transition between shallow and non-shallow routing, type is insufficient

*/
type: NavigationType;
/**
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/src/runtime/app/state/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const page = {
get route() {
return _page.route;
},
get shallow() {
return _page.shallow;
},
get state() {
return _page.state;
},
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/app/state/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <!--- file: +layout.svelte --->
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/src/runtime/app/state/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down
Loading
Loading