Skip to content

breaking: adjust how pushState/replaceState behave#16389

Closed
dummdidumm wants to merge 4 commits into
version-3from
shallow-routing-changes
Closed

breaking: adjust how pushState/replaceState behave#16389
dummdidumm wants to merge 4 commits into
version-3from
shallow-routing-changes

Conversation

@dummdidumm

@dummdidumm dummdidumm commented Jul 17, 2026

Copy link
Copy Markdown
Member

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:

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"
@pkg-svelte-dev

pkg-svelte-dev Bot commented Jul 17, 2026

Copy link
Copy Markdown

Install the latest version of @sveltejs/kit from 02c1322:

pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/02c1322b98c26061f4cc44fb3e70f327f91ed5e6

Open in pkg.svelte.dev: https://pkg.svelte.dev/repos/kit/pr/16389

Comment thread packages/kit/test/apps/basics/test/client.test.js Outdated
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 02c1322

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment thread packages/kit/src/runtime/client/client.js Outdated
@Nic-Polumeyv

Copy link
Copy Markdown
Contributor

Does the guard retained in update_state need an exception for initial render? #15927 is calling pushState from an $effect during hydration, which runs before started is set and hits the "before router is initialized" error. If you'd take it, I can do a small follow-up once this lands.

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
@ottomated

Copy link
Copy Markdown
Contributor

Closes #13569 I think

page.state = state;
}

const shallow_url = event.state[PAGE_URL_KEY] ? new URL(location.href) : null;

@Nic-Polumeyv Nic-Polumeyv Jul 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This recomputes what update_state already knew when it created the entry, and the await makes this branch asynchronous where it used to be sync, with no token guard. With a sync or cached reroute the await resolves in a microtask and nothing can interleave, but an async reroute on an uncached URL opens a real window (shallow entries restored from a previous session, since the reroute cache is per-session) in which a second back press or a link click interleaves, and the stale continuation still applies page.shallow, update_url and the history index bookkeeping.

Storing the shallow context next to PAGE_URL_KEY in the entry and reading it here synchronously would close that window and drop the per-popstate route matching. Roughly:

// update_state
...((resolved || (url === '' && page.shallow)) && {
	[PAGE_URL_KEY]: page.url.href,
	[SHALLOW_KEY]: resolved
		? { params: intent?.params ?? null, route_id: intent ? intent.route.id : null }
		: history.state[SHALLOW_KEY]
}),
// popstate
const stored = event.state[SHALLOW_KEY];
page.shallow = event.state[PAGE_URL_KEY]
	? {
			params: stored?.params ?? null,
			route: stored?.route_id != null ? { id: stored.route_id } : null,
			url: new URL(location.href)
		}
	: null;

Entries do outlive deploys, so a renamed route leaves a stale id behind, but that's the same tradeoff PAGE_URL_KEY and the persisted state already make.

}

if (nav) {
const after_navigate = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This block and the completion tail below (settled, token check, fulfil, to.scroll, afterNavigate, navigating.current = null, updating = false) mirror navigate() line for line. Extracting the onNavigate orchestration and the shared finish into helpers would leave one definition of the navigation lifecycle instead of two that can drift apart.

'@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

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.


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

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.

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

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

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

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

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

@Rich-Harris

Copy link
Copy Markdown
Member

closing in favour of #16449

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

5 participants