Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide access to history state #5478

Open
Rich-Harris opened this issue Jul 12, 2022 · 27 comments
Open

Provide access to history state #5478

Rich-Harris opened this issue Jul 12, 2022 · 27 comments
Labels
feature / enhancement New feature or request p1-important SvelteKit cannot be used by a large number of people, basic functionality is missing, etc.
Milestone

Comments

@Rich-Harris
Copy link
Member

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 #3236 (comment).

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

This was referenced Jul 12, 2022
@Rich-Harris Rich-Harris added the feature / enhancement New feature or request label Jul 12, 2022
@Rich-Harris Rich-Harris added this to the 1.0 milestone Jul 12, 2022
@benmccann benmccann added the p1-important SvelteKit cannot be used by a large number of people, basic functionality is missing, etc. label Jul 19, 2022
@Rich-Harris
Copy link
Member Author

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
Copy link
Member

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

@ramiroaisen
Copy link

ramiroaisen commented Jul 26, 2022

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:

  • We pass getters and setters instead of the object. eg: getState, setState.
  • We pass a js Proxy that passthrough getters and setters instead of the real object.

Getters and setters sounds better I think

Any ideas on this?

@dummdidumm
Copy link
Member

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
Copy link

ramiroaisen commented Jul 26, 2022

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
Copy link
Member

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
Copy link

ramiroaisen commented Jul 26, 2022

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
Copy link
Member

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
Copy link

ramiroaisen commented Jul 27, 2022

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
Copy link
Member Author

Rich-Harris commented Sep 1, 2022

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 #5478 (comment), 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
Copy link
Member Author

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
Copy link
Contributor

modal navigation

@Rich-Harris Does it totally fall off, will it not be considered?
#4560

@Rich-Harris
Copy link
Member Author

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
Copy link
Contributor

lukaszpolowczyk commented Sep 2, 2022

@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 sveltejs/svelte#5797 (comment)), 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
Copy link
Member Author

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
Copy link
Member

Couple of different thoughts:

  • hack: use private Svelte APIs to traverse the component tree upwards to the root and register all those callbacks there onMount. The root runs last so all would be available
  • reduce: don't allow the hook to access other components' snapshots
  • solve this in Svelte core: add a way to tell that the component, when unmounted, isn't destroyed, rather its state is preserved/the component is detached. Then we don't need additional API in SvelteKit. AFAIK this would also help other mobile-inspired routers who work stack-based and would like to see this built in

@david-plugge
Copy link
Contributor

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

@Rich-Harris
Copy link
Member Author

Rich-Harris commented Sep 3, 2022

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
Copy link
Contributor

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
Copy link

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.

@dummdidumm dummdidumm modified the milestones: 1.0, post-1.0 Oct 27, 2022
@Rich-Harris Rich-Harris mentioned this issue Jan 24, 2023
9 tasks
@AndreasHald
Copy link

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
Copy link
Contributor

@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
Copy link

raythurnvoid commented May 1, 2023

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
Copy link
Member

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

  • $page.state
  • a way to programmatically set history state only (pushState / replaceState)

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

@samal-rasmussen

This comment was marked as off-topic.

@dummdidumm

This comment was marked as off-topic.

@samal-rasmussen

This comment was marked as off-topic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature / enhancement New feature or request p1-important SvelteKit cannot be used by a large number of people, basic functionality is missing, etc.
Projects
None yet
Development

No branches or pull requests

10 participants