Skip to content

feat: use goto for shallow routing, deprecate push/replaceState#16449

Open
dummdidumm wants to merge 18 commits into
version-3from
shallow-routing-goto
Open

feat: use goto for shallow routing, deprecate push/replaceState#16449
dummdidumm wants to merge 18 commits into
version-3from
shallow-routing-goto

Conversation

@dummdidumm

@dummdidumm dummdidumm commented Jul 20, 2026

Copy link
Copy Markdown
Member

See discussion in #16389 - instead of beefing up push/replaceState and making some tough decisions, we deprecate and fold those methods into goto which now has a shallow: true option. Also deprecates replaceState option (on goto) in favor of replace, and adds persistState for the "I want the state to be there on reload" behavior.

There have been a few confusions with how shallow routing and pushState/replaceState work. That's why we're deprecating these two methods in favor of goto, which gets new features, which addresses these confusions/requests:

Also fixes #16457 / fixes #15618 / fixes #11452

dummdidumm and others added 6 commits July 17, 2026 16:47
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"
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
@pkg-svelte-dev

pkg-svelte-dev Bot commented Jul 20, 2026

Copy link
Copy Markdown

Install the latest version of @sveltejs/kit from d33a324:

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

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

@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d33a324

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

@svelte-docs-bot

Copy link
Copy Markdown

Comment thread documentation/docs/30-advanced/67-shallow-routing.md Outdated
Comment thread documentation/docs/30-advanced/67-shallow-routing.md Outdated
Comment thread documentation/docs/30-advanced/67-shallow-routing.md Outdated
Comment thread documentation/docs/30-advanced/67-shallow-routing.md Outdated

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.

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 is duplicative with the stuff above, we could streamline it. probably best to tweak the API and then come back to the docs though

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.

This is not duplicative it isn't mentioned anywhere else at the moment. persistState also does not change this; we don't swap out the server-loaded page for the real page once we know we're actually shallow-routing, we only persist state (as the option name says).

Comment thread packages/kit/src/exports/public.d.ts Outdated
Comment on lines +1273 to +1274
/** If `true`, preserves the browser's scroll position. */
noScroll?: boolean;

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
/** If `true`, preserves the browser's scroll position. */
noScroll?: boolean;
/**
* If `true`, scrolls to the top of the page, otherwise preserves the current scroll position.
* @default true
*/
resetScroll?: boolean;
/** @deprecated Use `resetScroll` instead */
noScroll?: boolean;

Comment thread packages/kit/src/exports/public.d.ts Outdated
Comment on lines +1275 to +1276
/** If `true`, keeps the currently focused element focused. */
keepFocus?: boolean;

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
/** If `true`, keeps the currently focused element focused. */
keepFocus?: boolean;
/**
* If `true`, resets focus to the `<body>` element, otherwise allows the current `document.activeElement` to remain focused.
* @default true
*/
resetFocus?: boolean;
/** @deprecated Use `resetFocus` instead */
keepFocus?: boolean;

@dummdidumm dummdidumm Jul 22, 2026

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.

let's keep the deprecation discussion out of this for now - I don't see a big reason to change this (and an argument against is that now everything's false by default whereas with your change some would be true by default), and we would have to adjust the data-sveltekit-... attributes as well

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 tackle it in a follow-up PR but I do think it needs to change. keepFocus is just misleading in the case where something is focused as a result of the navigation, which is extremely likely in the case of a modal (which should update the SFNSP and trap focus) — we're not keeping the previously focused element, we're just not actively resetting it. resetFocus is a much more accurate description of what's happening.

This was kind of already true but is more acute in the shallow routing case, because we probably need to switch the default in the shallow: true case to keepFocus: true (or resetFocus: false) — would feel weird to focus the <body> as a result of a shallow navigation.

some would be true by default

There's nothing wrong with an option defaulting to true — see e.g. config.csrf.checkOrigin or config.paths.relative or config.prerender.crawl.

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.

Opened #16479 so we don't lose this

Comment thread packages/kit/src/exports/public.d.ts Outdated
Comment thread packages/kit/src/exports/public.d.ts Outdated
Comment thread packages/kit/src/exports/public.d.ts Outdated
Comment thread packages/kit/src/exports/public.d.ts Outdated
Comment thread packages/kit/src/exports/public.d.ts Outdated
Comment thread packages/kit/test/ambient.d.ts Outdated

@Rich-Harris Rich-Harris left a comment

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.

As mentioned inline: I don't think goto(null) should be a thing, and I certainly don't think it should be a way to set state without navigating, because shallow: true is the way to set state without navigating.

For me the only question is whether relative URLs, including the empty string, are resolved against page.url or page.shallow.url ?? page.url, aka window.location. I incline towards the latter (which I think is what's already happening?)

Comment thread packages/kit/src/runtime/client/client.js Outdated
Comment thread packages/kit/src/runtime/client/client.js Outdated
Comment thread packages/kit/src/runtime/client/client.js Outdated
Comment thread packages/kit/test/apps/basics/test/client.test.js Outdated
}

interface Window {
shallow_navigation_log: Array<{

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.

side-note, would be cool if we could just log stuff and read the logs back like we do in the Svelte test suite. something for future us to consider

@Rich-Harris

Copy link
Copy Markdown
Member

Playing with this locally, so far so good. Really happy with how this is shaping up. One thought I have, which isn't new to this PR but maybe worth thinking about since we're currently deep in this topic: if I perform this sequence of actions...

  1. shallow route to /photo/xyz123
  2. reload the page
  3. hit back
  4. hit forward

...what should happen? Currently, step 4 will shallowly navigate me to /photo/xyz123 but that feels a tiny bit odd, since that's not where I just came from. It feels like reloading should 'upgrade' the shallow entry to a real one.

I tried to see what Instagram does in this situation, and the answer is 'it breaks'. (Maybe Meta needs to pay engineers more.) Unsplash does the second one — modal on click, full page on reload, full page on back-then-forward.

The question is: should this behaviour always apply? Should it vary on persistState? (Haven't thought that one through.) Should it be a whole new option? (Ideally not.) I don't think we need to resolve that question to merge this PR, but I do think we should figure it out before v3.

@Rich-Harris

Copy link
Copy Markdown
Member

Actually now that I think about it... would we need to upgrade the entire history in that case? Is it weird if you're thumbing through a bunch of photos 1, 2, 3, 4, 5, then go back to 3, reload to a full page, go back to a modal, then go forward to a full page and forward to another modal?

That's what Unsplash does. Genuinely no idea if there's a right answer here:

unsplash.mov

@Rich-Harris

Copy link
Copy Markdown
Member

Some bugginess around scroll handling: if I shallowly navigate from A to B, the document correctly doesn't scroll. If I scroll, then hit back, the document does scroll, incorrectly. If I then hit forward, it again scrolls incorrectly.

Related: noScroll: false isn't respected for a shallow navigation (nor is keepFocus: false). I think that defaulting to not resetting scroll/focus when shallowly navigating is correct, but I don't see any reason not to respect these options if they've been provided.

I think the solution is conceptually fairly simple: we preserve those options with the history entry, so the entry representing B would keep its noScroll, keepFocus and persistState options. (Rather than adding new keys for these alongside STATES_PERSISTED_KEY, I think we should probably get rid of that key and turn entry[STATES_KEY] into a { state, persistState, noScroll, keepFocus } object. In fact maybe it could accommodate HISTORY_INDEX and NAVIGATION_INDEX and PAGE_URL_KEY as well under a single, typed, object? i.e. history.state['sveltekit:metadata'] = { url, index, ... })

When we navigate back from B to A, we use B's noScroll and keepFocus options (they're associated with the B entry, but they represent the transition between A and B). Same when we go forward from A to B. What I'm not totally sure about is what should happen if you do history.go(-10) or history.go(10).

@Rich-Harris

Copy link
Copy Markdown
Member

Opened #16480 for the 'what should happen when we reload?' question

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