kit icon indicating copy to clipboard operation
kit copied to clipboard

`$page.state` is lost after page refresh

Open mpost opened this issue 1 year ago • 15 comments

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

  1. Click on "Replace state with banana => PageState shows {"filter":"banana"}
  2. Click on "Refresh from state.history" => PageState and history state show {"filter":"banana"}
  3. Refresh the browser page => PageState shows undefined
  4. 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

mpost avatar Mar 08 '24 12:03 mpost

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.

rChaoz avatar Mar 17 '24 01:03 rChaoz

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

teemingc avatar Mar 18 '24 15:03 teemingc

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

rChaoz avatar Mar 18 '24 21:03 rChaoz

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.

mpost avatar Mar 19 '24 20:03 mpost

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.

mpost avatar Mar 20 '24 14:03 mpost

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.

rChaoz avatar Mar 22 '24 22:03 rChaoz

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

teemingc avatar Apr 26 '24 14:04 teemingc

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.

Rich-Harris avatar Apr 29 '24 19:04 Rich-Harris

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.

rChaoz avatar Apr 29 '24 19:04 rChaoz

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?

yahao87 avatar Jun 28 '24 07:06 yahao87

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.

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.

yahao87 avatar Jul 01 '24 00:07 yahao87

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.

rChaoz avatar Jan 08 '25 22:01 rChaoz

Oh, also adjacent: #13139.

Conduitry avatar Jan 08 '25 22:01 Conduitry

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>

patte avatar Jan 30 '25 00:01 patte

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

samal-rasmussen avatar May 21 '25 13:05 samal-rasmussen