kit
kit copied to clipboard
`$page.state` is lost after page refresh
Describe the bug
When refreshing a page (or submitting a form) with a PageState, the PageState is lost. However, when checking the history.state['sveltekit:states'], we see that the expected state is still attached to the current history entry.
Reproduction
The following +page.svelte demonstrates the issue:
<script lang="ts">
import { browser } from '$app/environment'
import { replaceState } from '$app/navigation'
import { page } from '$app/stores'
let historyState = browser && history.state['sveltekit:states']
</script>
<div>App PageState: {$page.state.filter}</div>
<div>History state: {JSON.stringify(historyState)}</div>
<hr />
<button on:click={() => replaceState('', { filter: 'banana' })}>Replace state with banana</button>
<button on:click={() => (historyState = history.state['sveltekit:states'])}>
Refresh from state.history
</button>
After initial loading the $page.state.filter is undefined
- Click on "Replace state with banana
=> PageState shows
{"filter":"banana"} - Click on "Refresh from state.history"
=> PageState and history state show
{"filter":"banana"} - Refresh the browser page
=> PageState shows
undefined - Click on "Refresh from state.history"
=> history state shows
{"filter":"banana"}but page state remains undefined
Logs
No response
System Info
System:
OS: Windows 11 10.0.26063
CPU: (12) x64 Intel(R) Core(TM) i7-9850H CPU @ 2.60GHz
Memory: 13.65 GB / 31.71 GB
Binaries:
Node: 21.1.0 - C:\Program Files\nodejs\node.EXE
npm: 10.2.3 - C:\Program Files\nodejs\npm.CMD
pnpm: 8.6.7 - ~\AppData\Local\pnpm\pnpm.CMD
Browsers:
Chrome: 122.0.6261.112
Edge: Chromium (123.0.2420.10)
Internet Explorer: 11.0.26063.1
npmPackages:
@sveltejs/adapter-node: ^4.0.1 => 4.0.1
@sveltejs/kit: ^2.5.2 => 2.5.2
@sveltejs/vite-plugin-svelte: ^3.0.2 => 3.0.2
svelte: ^4.2.10 => 4.2.10
vite: ^5.1.2 => 5.1.2
Severity
serious, but I can work around it
Additional Information
No response
Just encountered this myself, took a while to debug. I just switched all of my modals to use shallow routing, as suggested by the docs. But because of this issue it seems like I'll have to abandon this work and keep using stores instead, since some of my modals contain forms, which close the dialog when submitted. Additionally, hitting the back button after this happens does nothing, because it pops the state from the history, but since $page.state was already undefined, nothing happens.
Additionally, I've encountered a bug where closing the model sometimes causes the whole page to go back. Which means that, sometimes, $page.state is set even when it shouldn't be anymore (back navigation already happened). I'm not sure how to reproduce this but it seems to happen quite often.
All in all, it seems that $page.state is generally out of sync with history.state.
Seems like this is noted as a caveat in the docs. I can't explain why though. cc: @Rich-Harris
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. https://kit.svelte.dev/docs/shallow-routing#caveats
That's very interesting. Maybe Svelte 5/associated SvelteKit major version will change this behaviour. I find it very difficult to find even a single use case of shallow routing that doesn't break with those "caveats".
I read those lines in the docs as well, but thought that it applied to server side rendering only.
If these caveats also apply on the client, shallow routing would only be useful in the simpler cases like a navigating to a dialog.
Chrome just added support for the new NavigationActivation api. This sounds like a accurate callback based api to get insights into the current PageState. Alas, it is chrome only atm.
Workaround:
import { derived } from "svelte/store"
import { page } from "$app/stores"
import { browser } from "$app/environment"
import deepEquals from "fast-deep-equal"
let oldState: App.PageState = {}
export const pageState = derived<typeof page, App.PageState>(page, (_, set) => {
if (!browser) return
setTimeout(() => {
const newState = history.state["sveltekit:states"] ?? {}
if (!deepEquals(oldState, newState)) {
oldState = newState
set(newState)
}
})
}, {})
Use this instead of $page.state. This correctly loads the state after a page reload and does not remove it on form submission. Additionally, it doesn't update the store when the state doesn't change but $page would update without the state changing.
Warning: SvelteKit version 2.12.0 broke this workaround, but this is fixed again in 2.15.2. Generally using the $page store is broken in these versions. Make sure to upgrade to 2.15.2+ if this isn't working for you, this took me a while to debug. Interestingly the first 1-2 navigations will work, only then it will break.
Going to keep this open while I don't know the reason for $page.state being reset on browser refresh https://github.com/sveltejs/kit/issues/11956#issuecomment-2004266264
However, for issues related to $page.state being lost after an enhanced form submission (which invokes invalidateAll) see https://github.com/sveltejs/kit/issues/11783
Consider one of the primary use cases that shallow routing was added to solve: Instagram-style navigation, where clicking on a photo on /[user] opens a preview page but also updates the URL.
If you reload the page, you'll get a server rendered page for a different route — /[user]/[photo]. The previous $page.state just doesn't apply here, and it could even be detrimental (depending on what you do with it). You certainly don't want to navigate from /[user]/[photo] back to /[user] (where the $page.state does apply) upon hydration, because that would cause flashing.
And in general that applies to any use of $page.state — because it's not available during SSR, it would at minimum cause some flashing and weirdness if it was applied post-hydration.
It's definitely possible that there are some cases where you do want to apply state on hydration, flickering be damned. Perhaps an option would make sense for that:
pushState('', state, {
hydrate: true
});
I don't think it should be the default though.
I believe an issue I have with this system is that the state isn't completely reset on re-load, but I'm not sure if this is fixable:
Page A -> pushState -> Reload
This results in a state where the back button does nothing (it pops history.state, but this doesn't affect $page.state as it was already). I don't remember but I think then hitting forward does apply the state which wasn't applied initially.
This issue is critical for those who want to display the popup again upon refreshing, as it disregards the default state behavior of the browser. Is there any solution for this?
Consider one of the primary use cases that shallow routing was added to solve: Instagram-style navigation, where clicking on a photo on
/[user]opens a preview page but also updates the URL.If you reload the page, you'll get a server rendered page for a different route —
/[user]/[photo]. The previous$page.statejust doesn't apply here, and it could even be detrimental (depending on what you do with it). You certainly don't want to navigate from/[user]/[photo]back to/[user](where the$page.statedoes apply) upon hydration, because that would cause flashing.And in general that applies to any use of
$page.state— because it's not available during SSR, it would at minimum cause some flashing and weirdness if it was applied post-hydration.It's definitely possible that there are some cases where you do want to apply state on hydration, flickering be damned. Perhaps an option would make sense for that:
pushState('', state, { hydrate: true });I don't think it should be the default though.
The behavior of SSR hydrating should be a concern for developers implementing SSR, not an issue to be solved by blocking actual browser functionality. Isn't it natural for SSR operations to ignore history.state by default? Why should the existence of SSR prevent the use of history.state in the browser entirely?
Do other frameworks have implementations that completely block basic browser functionality? I think it's a serious problem for a framework to prevent the use of the browser's basic functionality.
The behavior of SSR hydrating should be a concern for developers implementing SSR, not an issue to be solved by blocking actual browser functionality.
Could not agree more. Page/shallow state it kept by the browser when reloading; it's SvelteKit that chooses to ignore it. This feels like a rather arbitrary decision in favour of SSR, and while I totally agree that it can have bad consequences with SSR, it's pretty much guaranteed to have issues with CSR. At the very least it shouldn't have this behaviour for pages with SSR disabled.
Oh, also adjacent: #13139.
Workaround:
I use the following workaround for the case: form submits with redirect in shallow routed page. By doing history.back() before doing the redirect, the shallow routing is "un-done", then a navigation to the redirect is done. The shallow route and it's history entry properly disappears.
Does that make sense to anybody?
<!--
scenario:
on route: /clients
-> shallow routing to /clients/new (open dialog)
-> goto /clients/1 (on form submit in dialog)
-> go back
result:
url: /clients/new and the dialog is still open
expected
url: /clients and the dialog is closed
what this does:
intercepts navigations on the page that initiates shallow routing
on navigate away from the shallow routed page (window.location != page.url)
-> cancel the navigation, go back (undo shallow route), then navigate to desired page
usage:
<ShallowRoutingFix />
-->
<script lang="ts">
import { beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
let afterPopGoTo: string | null = $state(null);
beforeNavigate((e) => {
if (e.to && window.location.pathname !== page.url.pathname && e.type === 'goto') {
console.log('shallow routing fix: doing things to your history, look here if something is broken');
const to = e.to.url.pathname;
e.cancel();
afterPopGoTo = to;
history.back();
}
});
</script>
<svelte:window
on:popstate={() => {
if (afterPopGoTo) {
const goTo = `${afterPopGoTo}`;
afterPopGoTo = null;
setTimeout(() => {
goto(goTo, { invalidateAll: true });
}, 1);
}
}}
/>
setting setTimeout(() => { goto(to, { invalidateAll: true }); }, 1); inside beforeNavigate would also work (avoids the state and popstate eventlistener) but might be more even unstable.
usage example
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { page } from '$app/state';
import { goto, preloadData, pushState } from '$app/navigation';
import ClientNewPage from './new/+page.svelte';
import ShallowRoutingFix from '$lib/utils/shallowRouting/shallow-routing-fix.svelte';
async function tryShallowRouting(
e: MouseEvent & {
currentTarget: EventTarget & (HTMLAnchorElement | HTMLButtonElement);
},
key: keyof App.PageState,
) {
if (
e.shiftKey || // link is opened in a new window
e.metaKey || // or a new tab (mac: metaKey, win/linux: ctrlKey)
e.ctrlKey
// should also consider clicking with a mouse scroll wheel
)
return;
// prevent navigation
e.preventDefault();
const href = (e.currentTarget as HTMLAnchorElement).href;
// run `load` functions (or rather, get the result of the `load` functions
// that are already running because of `data-sveltekit-preload-data`)
const result = await preloadData(href);
if (result.type === 'loaded' && result.status === 200) {
pushState(href, { [key]: result.data as App.PageState[typeof key] });
} else {
// something bad happened! try navigating
goto(href);
}
}
</script>
<ShallowRoutingFix />
<a href="/clients/new" onclick={async (e) => tryShallowRouting(e, 'clientNew')}> Add Client </a>
<Dialog.Root
bind:open={
() => !!page.state.clientNew,
(v) => {
if (!v) {
history.back();
}
return v;
}
}
>
<Dialog.Content>
{#if page.state.clientNew}
<ClientNewPage data={page.state.clientNew} />
{/if}
</Dialog.Content>
</Dialog.Root>
@rChaoz Thank you for the workaround. It works well most of the time, but on some particular pages it doesn't. I haven't been able to work out why.