sveltejs / kit

web development, streamlined
https://kit.svelte.dev
MIT License
18.15k stars 1.84k forks source link

Provide access to history state #5478

Open Rich-Harris opened 2 years ago

Rich-Harris commented 2 years ago

Describe the problem

There are a number of cases where it's valuable to be able to read and write state associated with a particular history entry. For example, since there's no concept of bfcache when doing client-side navigation, things like scroll position (for elements other than the body) and form element values are discarded.

Associating user-controlled state with history entries would also make it possible to do things like history-controlled modals, as described in https://github.com/sveltejs/kit/issues/3236#issuecomment-1011436921.

Describe the proposed solution

When creating a new history entry with history.pushState or history.replaceState (in user-oriented terms, navigating via a <a> click intercept or goto(...)), we create a new empty state object and store it in a side table (using a similar mechanism to scroll_positions, which is serialized to sessionStorage when the user navigates cross-document).

Reading state

After navigation, this object is available in afterNavigate...

import { afterNavigate } from '$app/navigation';

afterNavigate(({ from, to, state }) => {
  console.log(state); // {} — though perhaps it could also be `null` for new entries? not sure
});

...and in a store:

import { page } from '$app/stores';

$: console.log($page.state); // {}

Writing state

So far, so useless. But we can write to the current state right before we leave the current history entry using beforeNavigate:

import { beforeNavigate, afterNavigate } from '$app/navigation';

/** @type {HTMLElement} */
let sidebar;

beforeNavigate(({ from, to, state }) => {
  state.sidebar_scroll = sidebar.scrollY;
});

Then, if we go back to that entry, we can recover the state:

afterNavigate(({ from, to, state }) => {
  sidebar.scrollTo(0, state.sidebar_scroll ?? 0);
});

Programmatically setting state

You might want to show a modal that can be dismissed with the back button (or a backwards swipe, or whatever device/OS-specific interaction signals 'back'). Together with shallow navigation (the concept, if not the name), you could do that with goto:

goto(null, {
  state: {
    modal: true
  },
  shallow: true // or `navigate: false` — see https://github.com/sveltejs/kit/issues/2673#issuecomment-1091736795
});

null means 'the current URL' and is open to bikeshedding

Then, a component could have something like this:

{#if $page.state.modal}
  <Modal on:close={() => history.back()}>
    <!-- modal contents -->
  </Modal>
{/if}

Closing the modal would cause a backwards navigation; a backwards navigation (triggered via the modal's 'close' button or the browser's chrome) would close the modal.

Alternatives considered

The main alternative is to attempt to automatically track the kind of state that people would want to store (i.e. scroll positions, form element values) so as to simulate the behaviour of a cross-document navigation with bfcache enabled. This comes with some real implementation challenges (capturing the data isn't trivial to do without risking performance issues, and there's no way to reliably determine equivalence between two separate DOM elements), but moreover I'm not certain that it's desirable. Things like automatically populating form elements can definitely go wrong.

One aspect of the design that I'm not sure about is whether the state should be a mutable object or an immutable one. Probably immutable (especially since the Navigation API, which we'd like to adopt eventually, uses immutable state), which makes me wonder if we need to expose methods for getting/setting state inside beforeNavigate and afterNavigate rather than just a state object.

We might also need some way to enforce that state is serializable (most likely as JSON) so that it can be persisted to sessionStorage, so that it can be recovered when traversing back from another document. Then again perhaps documentation is the solution?

Importance

would make my life easier

Additional Information

No response

Rich-Harris commented 1 year ago

One thought I just had: the user might scroll or interact with form elements while the navigation is in progress. I wonder if we need a way to write to state immediately before the navigation is committed

dummdidumm commented 1 year ago

Just to connect the two: This would also help with #5478 by giving people easier access to scroll positions.

ramiroaisen commented 1 year ago

Just start working on this. One thing that come to my mind is that if the user changes state in afterNavigate we have to notify the $page store. But how can we know if the user updated the state.. It's an interior mutability of the same object.

Possible solutions:

Getters and setters sounds better I think

Any ideas on this?

dummdidumm commented 1 year ago

getState and setState make it easier for us but I'm not sure if I like it Design-wise, doesn't feel like something we do in other places. Either way we need to make sure that it's only possible to change state inside the hook.

ramiroaisen commented 1 year ago

getState and setState make it easier for us but I'm not sure if I like it Design-wise, doesn't feel like something we do in other places. Either way we need to make sure that it's only possible to change state inside the hook.

Why only in the hook? Why not $page.state.some_state = x?

We can only provide the state in the page store and not the actual object in hooks like:

beforeNavigate(() => $page.state = x)

That may be easier for us, and the in client we do:

page.subscribe($page => page_states[CURRENT_INDEX] = $page.state)

dummdidumm commented 1 year ago

This sounds appealing at first, but what if the user presses the back button, what should happen to all the state that was updated outside of the navigation hooks? By keeping it scoped to them, we don't have to answer that question

ramiroaisen commented 1 year ago

This sounds appealing at first, but what if the user presses the back button, what should happen to all the state that was updated outside of the navigation hooks? By keeping it scoped to them, we don't have to answer that question

The state that users may change is kept in a page_states record similar to scroll_positions that is indexed by history index.

When user clicks back button happens the following:

beforeNavigate(() => $page.state === currentState) afterNavigate(() => $page.state === previousState)

We update the page store between beforeNavigate and afterNavigate like in any forward navigation, the state is not kept in the history.state but in a plain record that gets saved to sessionStorage in beforeunload

dummdidumm commented 1 year ago

What if people do $page.state.substate = 'foo'? That means the state was mutated, it's not possible to get that back. Although now that I've written this down, if we require the state to be JSON-serializable, we can easily clone it. Still, is it obvious to the user that all state will be lost on navigation / state will be reset to the prior state on back navigation?

ramiroaisen commented 1 year ago

What if people do $page.state.substate = 'foo'? That means the state was mutated, it's not possible to get that back. Although now that I've written this down, if we require the state to be JSON-serializable, we can easily clone it. Still, is it obvious to the user that all state will be lost on navigation / state will be reset to the prior state on back navigation?

If someone mutates the state like $page.state.key = value they will only mutate the state related with the current history entry other entries will be untouched

EG: page_states: Record<number, App.PageState> where number is the current history index like in scroll_positions and App.PageState is an interface that can be typed by the user like in App.Session

Rich-Harris commented 1 year ago

We definitely only want to set state at the instant we're navigating to another page (the exception being cross-document navigations, where we need to do it right before unload). Nothing else would really make any sense.

Per https://github.com/sveltejs/kit/issues/5478#issuecomment-1189562537, we can't actually use beforeNavigate for this. I actually think it might make sense to have a dedicated lifecycle function — maybe onSnapshot?

onSnapshot(() => {
  return {
    sidebar_scroll: sidebar.scrollY
  };
});

The snapshot would be available in afterNavigate:

afterNavigate(({ from, to, snapshot }) => {
  sidebar.scrollY = snapshot?.sidebar_scroll;
});

I like the name snapshot for this, I think it does a good job of communicating what it is. I'm less sure about onSnapshot — don't hate it, but am open to alternatives.

Rich-Harris commented 1 year ago

Thinking more about the modal navigation case, I think it's probably a separate concept than snapshot. state describes the whole history entry, while snapshot is a snapshot of page state upon leaving an entry.

state is something that probably belongs on the $page store:

<script>
  import { page } from '$app/stores';
  import { goto } from '$app/navigation';
</script>

<button
  on:click={() => {
    goto($page.url.href, {
      state: {
        modal: true
      }
    })
  }
>
  show modal
</button>

{#if $page.state.modal}
  <div class="modal">...</div>
{/if}
lukaszpolowczyk commented 1 year ago

modal navigation

@Rich-Harris Does it totally fall off, will it not be considered? https://github.com/sveltejs/kit/discussions/4560

Rich-Harris commented 1 year ago

Seems very hard to understand, honestly. A lot more complex — semantics get very fuzzy around layouts and load functions etc — and less flexible than an arbitrary state object

lukaszpolowczyk commented 1 year ago

@Rich-Harris My goto(x, {in: y}) means "go to page x while staying on page y". And I find it fabulously easy to understand.

backpage/backpages stores y page (lowest current page).

layout and load functions etc - is fuzzy(truth), but only in the way that it uses slot, just as layout uses slot. Then y is as if an additional extra layer of layout, and is instead of the normal layout of x. (This is a place for people with more knowledge, whether it should be like that). Url changes to the one from page x, but the content is from y and from x.

As I write such things, I assume that you, as authors and members, know SvelteKit incomparably better than I do, you know it inside out. I assume that I may not describe something 100%, because I will not predict something. I assume that you can be inspired by what I write, for example, and at least partially use or take and improve it.

I'm afraid (this is my personal feeling and experience) that due to the fact that I am an outsider, there is a certain reluctance to what I will write, even though the content can be, for example, realized one to one after a year and a half (e.g. this https://github.com/sveltejs/svelte/issues/5797#issuecomment-1162544060), and even the justification is as there. Or there is another reason for my unhappiness. (certainly using a translator doesn't help with communication)

As for flexibility - maybe. I was only thinking about modal navigation. But I even considered more complexity, such as using named slots. (I didn't describe it because I thought you just didn't want something flexible, but something more specific. so much flexibility may not even be needed now) Of course, using a simple component gives unlimited flexibility, but it is also less convenient, less easy, you have to implement it more manually. It does not tie the url to a specific page, it can be a blank page. These are disadvantages for me.

I don't know what are the scenarios where goto in would not solve the problem, or would do it in an ugly way?

The $page.state itself seems to interfere with that? It could be one with the other.

Rich-Harris commented 1 year ago

Realised the snapshot idea needs more work. Since the snapshot is only used in afterNavigate, it wouldn't make sense to have onSnapshot in a component that didn't after afterNavigate, so a signature like this might make more sense...

onNavigate(
  ({ snapshot }) => {
    sidebar.scrollTo(0, snapshot?.sidebar_scroll ?? 0);
  },
  () => ({
    sidebar_scroll: sidebar.scrollY
  })
);

...except that it's not clear how to associate a particular snapshot with a particular component in a way that survives the component's destruction. If there was only one snapshot for a given history entry that wouldn't be a problem, but you might have a situation like this:

// src/routes/foo/+layout.svelte
onNavigate(
  ({ snapshot }) => {
    console.log(snapshot?.layout_state); // 1
    console.log(snapshot?.page_state); // undefined
  },
  () => ({
    layout_state: 1
  })
);
// src/routes/foo/+page.svelte
onNavigate(
  ({ snapshot }) => {
    console.log(snapshot?.layout_state); // undefined
    console.log(snapshot?.page_state); // 2
  },
  () => ({
    page_state: 2
  })
);
dummdidumm commented 1 year ago

Couple of different thoughts:

david-plugge commented 1 year ago

To clarify, this doesn´t work in ssr right?

Rich-Harris commented 1 year ago

Correct, $page.state would live in sessionStorage so you wouldn't have access to it during SSR.

use private Svelte APIs

I think we have to treat this as a last resort — the less we have that sort of coupling the better.

add a way to tell that the component, when unmounted, isn't destroyed, rather its state is preserved/the component is detached

This wouldn't solve the problem of navigating offsite then hitting 'back'. It's also a guaranteed source of memory leaks, unfortunately.

It occurs to me that we could reliably associate snapshots with components if we restricted it to page/layout components. Essentially we do this:

<!-- src/routes/some/+page.svelte -->
<script>
  export const snapshot = {
    capture: () => sidebar.scrollTop,
    apply: (y) => sidebar.scrollTo(0, y)
  };
</script>

It would be an unusual API but I can't think of any compelling alternatives. The nice thing about this is it's completely orthogonal to beforeNavigate and afterNavigate, which means a) we can implement it completely separately from $page.state, b) it doesn't affect those lifecycle functions, meaning we can remove the breaking change label, and c) unlike afterNavigate we only need to run the apply function if a snapshot exists, meaning developers don't have to handle the no-snapshot case.

david-plugge commented 1 year ago

So it´s not suitable for modals in my case as i want to support them in ssr too. Any idea/plan how to implement that? I´m currently using searchParams, was just wondering if maybe there is a better (more sveltekit way) solution.

ZetiMente commented 1 year ago

So it´s not suitable for modals in my case as i want to support them in ssr too. Any idea/plan how to implement that? I´m currently using searchParams, was just wondering if maybe there is a better (more sveltekit way) solution.

Hi @david-plugge, did you find a solution? I'm looking to have a modal based route, and don't really have any idea where to start and ideally have SSR. Although guess could give up SSR if this issue makes it simple but don't know the timeline on this issue's priority.

AndreasHald commented 1 year ago

When modals is implemented, could they theoretically be implemented in the same way pages are? It would be really useful if modals could have a load function, action or snapshot. For us at least we usually use modals for forms. Having all those features baked in would be really nice.

david-plugge commented 1 year ago

@ZetiMente completely missed your comment, very sorry. In case you are still looking for a way to render modals on the server:

<script lang="ts">
    import { page } from '$app/stores';
    import Modal from './Modal.svelte';

    $: showModal = $page.url.searchParams.has('modal');
</script>

{#if showModal}
    <Modal />
{/if}

<a href="/?modal">open modal</a>

But keep in mind you cannot properly use sveltekit actions at the moment when the path does not match the path of the action.

raythurnvoid commented 1 year ago

I'm not sure whether i'm missing something, but I don't understand why this issue is still open since svelte-kit is not "overwriting" the whole history.state anymore.

I'm able to accomplish everything I need with the following:

// persist component state
history.replaceState(
    {
        ...history.state,
        state: {
            ...history.state.snapshot,
            search: state
        }
    },
    '',
    window.location.href
);

// restore component state
const state = browser
        ? history.state?.state?.search ?? {}
        : {}

I personally feel this way of interacting with the history seems more flexible than snapshot API, I've tried to accomplish the same with it, but I ended up to the conclusion that is actually harder because snapshots are restored after component initialization while I can just read history.state on component init that is much better.

dummdidumm commented 5 months ago

With shallow routing implemented, 90% of the things described in this feature request are available now:

The thing that's not there yet is manipulating state in beforeNavigate

samal-rasmussen commented 5 months ago

@dummdidumm "The thing that's not there yet is manipulating state in beforeNavigate"

I just started using pushState/replaseState and realized that they don't trigger beforeNavigate, and so I cannot ask the user to confirm before navigating away from a modal form. I have a rather big webapp that will definitely have complex forms in modals. Users definitely want to be asked for confirmation before loosing complex form state.

dummdidumm commented 5 months ago

Could you open a different issue for that?

samal-rasmussen commented 5 months ago

Done: #11776