breaking: adjust how pushState/replaceState behave#16389
Conversation
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"
|
Install the latest version of pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/02c1322b98c26061f4cc44fb3e70f327f91ed5e6Open in |
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
🦋 Changeset detectedLatest commit: 02c1322 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
Does the guard retained in |
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
|
Closes #13569 I think |
| page.state = state; | ||
| } | ||
|
|
||
| const shallow_url = event.state[PAGE_URL_KEY] ? new URL(location.href) : null; |
There was a problem hiding this comment.
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 = ( |
There was a problem hiding this comment.
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` |
There was a problem hiding this comment.
I thought we were going to recommend using snapshot instead of this
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
| 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. |
There was a problem hiding this comment.
| 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`. |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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`. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
| 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 |
There was a problem hiding this comment.
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'
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
What exactly would I be doing with that info? i.e. what's the use case of boolean vs type?
There was a problem hiding this comment.
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
|
closing in favour of #16449 |
There have been a few confusions with how shallow routing and
pushState/replaceStatework. It was several parts, which are all addressed in this PR:page.url/params/routeare not updated. This still is that way, but the newpage.shallow.url/params/routeobject now gives you the information about the user-visible url. Closes$pagestore doesn't update when you set search params in the URL usinghistory#10661, closes page.url does not update when pushState is called #13569pushState/replaceStatewere only concerned with shallow routing, and when you reload you likely land on a different page where the history state is unfitting. 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 }).gotowill always persist state. Closes Keephistory.statein sync with$page.state#13293, closes$page.stateis lost after page refresh #11956pushState('', ...). Closes Make easier to use View Transitions with shallow routing #11759, closes pushState/replaceState don't trigger beforeNavigate #11776pushState/replaceState(null, ...)which basically means "end shallow routing mode and revert the visible url to what it was before"