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

Add navigation guards to liveSocket #3051

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

SteffenDE
Copy link
Collaborator

This PR introduces a new option that can be specified when creating a livesocket instance: navigation. It is inspired by navigation guards in SPA routers like Vue Router.

navigation is an object that can define two functions: beforeEach and afterEach. The beforeEach function is called before each navigation event and can for example be used to store custom scroll positions. If the beforeEach function returns false, the navigation will be aborted. This can be used to implement a warning when the user tries to navigate while a form is still being edited. The afterEach function is called after a navigation event has been completed. It can be used to restore the scroll position that was stored in the beforeEach function.

Example to restore a custom scroll position:

let liveSocket = new window.LiveView.LiveSocket("/live", Socket, {
    params: {_csrf_token: csrfToken},
    navigation: {
        beforeEach(_to) {
            if(document.querySelector("#my-scroll-container")) {
                customScrollPosition = document.querySelector("#my-scroll-container").scrollTop
            }
        },
        afterEach(_to) {
            if (document.querySelector("#my-scroll-container")) {
                document.querySelector("#my-scroll-container").scrollTop = customScrollPosition
            }
        }
    }
})

Example to prevent navigation when a form submit is pending. This example assumes that the form has a custom attribute, that could be set in a phx-change handler:

let liveSocket = new window.LiveView.LiveSocket("/live", Socket, {
    params: {_csrf_token: csrfToken},
    navigation: {
        beforeEach(_to) {
            if(document.querySelector("form[data-submit-pending]")) {
                return confirm("Do you really want to leave the page?")
            }
        }
    }
})

window.onbeforeunload = function(e) {
    if(document.querySelector("form[data-submit-pending]")) {
        return "Do you really want to leave the page?"
    }
};

Fixes #2107.

This PR should not be accepted in the current state, but instead just show the idea and maybe discuss how such an API could be optimized, if this is something that we want.

@SteffenDE SteffenDE force-pushed the navigation_callbacks branch 3 times, most recently from 8e7d50b to eb6732a Compare January 24, 2024 22:18
@josevalim
Copy link
Member

We typically dispatch browsers events for this sort of callbacks. Would it make more sense to use events here too? Or is there a difference between them?

@SteffenDE
Copy link
Collaborator Author

Events could probably work for the scroll restoration use case, but for preventing navigation this needs to happen synchronously. I guess we could move the actual navigation itself to an event listener and then let users prevent it by calling stopPropagation?

@josevalim
Copy link
Member

Oh, good call on being synchronous. Yeah, for me that justifies the difference. :) TY!

@chrismccord
Copy link
Member

This interface makes sense and it also gives folks a place to perform view transitions. Perhaps we also want the callback to support an async interface as well?

@SteffenDE
Copy link
Collaborator Author

View transitions are interesting indeed. I'll try to adapt the PR to also allow returning a promise from the callbacks later today. How would the usage with view transitions look like?

let liveSocket = new window.LiveView.LiveSocket("/live", Socket, {
    params: {_csrf_token: csrfToken},
    navigation: {
        beforeEach(_to) {
            return new Promise((resolve) => document.startViewTransition(() => resolve()));
        }
    }
})

What I'm still thinking about is the parameters. Probably we want to pass both the old and new route in the callbacks. Do you have any opinion which format would be the best? (Currently I simply use the href string)

@SteffenDE SteffenDE force-pushed the navigation_callbacks branch 3 times, most recently from ea429a2 to c034c38 Compare January 25, 2024 18:14
@SteffenDE
Copy link
Collaborator Author

I changed to callbacks to beforeEach(from, to) and afterEach(from, to). Both parameters get passed the full href string and both can now return promises as well.

Next step: add some tests.

@SteffenDE
Copy link
Collaborator Author

Still missing: documentation

@SteffenDE SteffenDE marked this pull request as ready for review January 25, 2024 23:19
@SteffenDE
Copy link
Collaborator Author

You can have a look at view transitions in action: just run npm run e2e:server and visit http://localhost:4000/navigation/a. On Page B you can test the form confirmation.

@SteffenDE SteffenDE force-pushed the navigation_callbacks branch 3 times, most recently from 9bdfcbd to 2dcfee7 Compare January 25, 2024 23:39
@SteffenDE SteffenDE mentioned this pull request Jan 26, 2024
10 tasks
@SteffenDE SteffenDE changed the title Proof of concept: Add navigation guards to liveSocket Add navigation guards to liveSocket Jan 26, 2024
@SteffenDE
Copy link
Collaborator Author

Docs done, ready for review and final touches.

@breunigs
Copy link
Contributor

breunigs commented Feb 5, 2024

I believe this patch/approach is in general a good idea, but I don't think the provided example fixes #2107 fully. Consider these steps to reproduce:

  1. I am on page A, scrolling #element to 500px
  2. I click on a link leading to page B. Example stores scrollPositions["element"] = 500.
  3. I click on a link leading to page A. Example restores #element's scroll to 500px, even though I expected to be scrolled to the top (or some other #anchor).

This issue goes away by storing the scroll state (or any other state for that matter) in the browser's history state, the same way LiveView already does for the body's scroll position:

let state = history.state || {}
state.customElementScrollState = document.getElementById("customElement").scrollTop
history.replaceState(state, "", window.location.href)

// or reuse LV's Browser.updateCurrentState from LV code if that is reachable

Obviously, both app code and LiveView code now contest for the same namespace. This is an easy fix, by designating a well known key for either LV, or the user to put their own state, or potentially provide an API.

@SteffenDE
Copy link
Collaborator Author

That’s a very good point. I’ll try to adapt the API to incorporate the history state.

livesocket instance: `navigation`.

Navigation is an object that can define two functions: `beforeEach` and
`afterEach`. The `beforeEach` function is called before each navigation
event and can for example be used to store custom scroll positions. If
the `beforeEach` function returns `false`, the navigation will be aborted.
This can be used to implement a warning when the user tries to navigate
while a form is still being edited. The `afterEach` function is called
after a navigation event has been completed. It can be used to restore
the scroll position that was stored in the `beforeEach` function.
@SteffenDE
Copy link
Collaborator Author

@breunigs have a look at 03fb8bf

@breunigs
Copy link
Contributor

breunigs commented Feb 6, 2024

I tested it in my real world project and the scroll restoration example now works with a 1:1 copy and paste (and setting the data attribute).

@breunigs
Copy link
Contributor

After some further testing, I have found that the userData is not available on a browser forward button navigation. Here's a reproducer that shows the issue: breunigs/liveviewtest1@cc2fe66

  1. setup the repo (git clone https://github.com/breunigs/liveviewtest1 && cd liveviewtest1 && mix deps.get && cd deps/phoenix_live_view/assets && npm install && cd .. && mix deps.get && mix assets.build && cd ../.. && mix phx.server)
  2. open localhost:4000
  3. open the browser's dev tools, optionally filter by nav to show only reproducer's logs
  4. click on the "goto page 2" link
  5. observe that it saved the dummy user data for page1 and correctly displays that there's no user data for this new page
  6. use the browser's back button
  7. observe that it saved the dummy user data for page2 and correctly displays the saved dummy data
  8. use the browser's forward button
  9. observe that it incorrectly shows user data as "undefined"

This is reproducible in Firefox and Chrome for me. In general, browsers do retain history state even on forward navigation, so this is not a browser limitation. I don't have a hunch why this happens with the PR (at least not yet).

@SteffenDE
Copy link
Collaborator Author

@breunigs this is sadly a limitation of the history API.

When you navigate to a new page, we save the userData in the "old" history state. The history is like a stack. So starting on page 1 and then navigating to page 2, the history looks like this (new entries on top):

{type: "patch", id: "phx-..."}
{type: "patch", id: "phx-...", userData: ...}

Note that on the new page, the userData is never set. If you now navigate back in the history, the userData is restored. The problem is that you cannot save new data when navigating back, because the browser only tells you that you navigated back after the navigation is already done.
If you navigate forward from page 1 to page 2 again, the empty state (on top above) is restored. So if you want to ensure that a scroll state is restored in any case, you would need to do something like LiveView does internally: proactively update the current history entry.

It is worth discussing if there should be an API to not need to manually change the history state. Something like

let scrollTimer
window.addEventListener("scroll", _e => {
  clearTimeout(scrollTimer)
  scrollTimer = setTimeout(() => {
    let scrollPositions = {}
    Array.from(document.querySelectorAll("[data-restore-scroll]")).forEach(el => {
      scrollPositions[el.id] = el.scrollTop
    })
    liveSocket.updateNavigationUserdata(data => Object.assign(data, {scrollPositions}))
  }, 100)
})

Maybe there should also be another parameter (?) in beforeEach that tells us if we are in a popstate situation beforeEach(_to, _from, popped), because returning data does not really make sense in that case.

@SteffenDE SteffenDE marked this pull request as draft February 13, 2024 20:26
@SteffenDE
Copy link
Collaborator Author

I'll need to think about this a little more.

@breunigs
Copy link
Contributor

breunigs commented Feb 13, 2024

Just for my understanding: so with your PR and me navigating back, the order of events is like this:

  1. I click back
  2. browser navigates back
  3. browser informs LV (via popstate)
  4. LV processes navigation.beforeEach
  5. LV renders
  6. LV processes navigation.afterEach

And obviously after step 3 we can't access the history entry we need to access?

If so, then I presume something like https://caniuse.com/mdn-api_navigation_navigate_event would do the trick, but that API isn't even usable in all modern browsers, let alone the ancient ones LV still supports.

@SteffenDE
Copy link
Collaborator Author

If so, then I presume something like https://caniuse.com/mdn-api_navigation_navigate_event would do the trick, but that API isn't even usable in all modern browsers, let alone the ancient ones LV still supports.

Yes, exactly.

@bcardarella
Copy link
Contributor

@SteffenDE I don't want to derail you but have you looked into using the History API for this? I implemented this solution in EmberJS years ago. When pushing history onto the stack you can include state information, which I used to stuff the x,y scroll positions so when the state was popped off the stack and such state was available the scroll position was restored:

https://github.com/DockYard/ember-router-scroll/pull/14/files

@SteffenDE
Copy link
Collaborator Author

@bcardarella yes, actually LiveView already uses the history state for storing the scroll position. I also tried to expose the history state in the more generic navigation guard API proposed in this PR (03fb8bf).

I didn't have the time to properly rethink this, but the current problem is that the API proposed here raises some expectations that it cannot fulfill, namely that we can "prevent" a navigation. Without the navigate event, this is always a hack, because we cannot actually prevent a navigation that is caused by a history.back() / forward.

It may be worth splitting the features into:

  1. a generic method to store values in the history without conflicting with LiveView (sth like liveSocket.updateNavigationUserdata)
  2. a method to prevent navigations (only ones that are not related to history) to support the "do you really want to leave?" functionality.

@rhcarvalho
Copy link
Contributor

Very interesting ideas here. I'd be particularly interested in the "do you really want to leave?" functionality for user-edited forms. Didn't find any issue tracking that particular problem to subscribe to.

Is there a known community-powered solution to that that someone could share a link? 💜

@greven
Copy link

greven commented May 16, 2024

I have several multi page forms where one can navigate between steps, I explored a way to prevent data loss when clicking the steps but figured out it was impossible to prevent navigation since there isn't a after navigation hook!

This would be a godsend for this layout pattern for sure. 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

scroll other element than body on history navigation
7 participants