Skip to content

Commit

Permalink
add documentation for navigation guards
Browse files Browse the repository at this point in the history
  • Loading branch information
SteffenDE committed Jan 30, 2024
1 parent 6b9e641 commit 490ed31
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
* Add `validate_attrs` to slots
* Support `phx-viewport` bindings in scrollable containers
* Perform client redirect when trying to live nav from dead client to avoid extra round trip
* Add `navigation` option to `LiveSocket` with `beforeEach` and `afterEach` callbacks to intercept live navigation

## 0.20.3 (2024-01-02)

Expand Down
26 changes: 26 additions & 0 deletions assets/js/phoenix_live_view/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@
* @param {Object} [opts.localStorage] - An optional Storage compatible object
* Useful for when LiveView won't have access to `localStorage`.
* See `opts.sessionStorage` for examples.
*
* @param {Object} [opts.navigation] - The optional object for defining navigation guards.
* Useful to perform custom logic before navigating away from a LiveView. For example:
*
* navigation: {
* beforeEach(to, from) {
* console.debug(`Navigating to: ${to}; from: ${from}.`)
* // return false to cancel navigation
* if(document.querySelector("form[data-submit-pending]")) {
* return confirm("Do you really want to leave with unsubmitted changes?")
* }
* }
* }
*/

import {
Expand Down Expand Up @@ -833,6 +846,18 @@ export default class LiveSocket {
}
}

// Every function that performs a navigation must be wrapped by a call to withNavigationGuard.
// This ensures that the user can cancel a navigation or store some state to restore
// after navigating.
//
// withNavigationGuard should be called with the href string that is being navigated to,
// the href string that is being navigated from, and a callback that performs the navigation.
// A third and optional callback will be invoked in case the navigation is canceled. This
// callback is currently only used in the "popstate" case, because when popstate is invoked
// the navigation already happened (i.e. an entry from the history is already popped).
// Therefore, we push the previous location again, see bindNav for details.
//
// When the callback is done, it must call `afterNavigation` with the same arguments (to, from).
withNavigationGuard(to, from, callback, cancel = function(){}){
// the beforeEach navigation guard can return a promise that must resolve
// to false in order to cancel the navigation
Expand All @@ -847,6 +872,7 @@ export default class LiveSocket {
})
}

// must be called after a navigation was performed, see `withNavigationGuard`.
afterNavigation(to, from){
this.navigationCallbacks["afterEach"](to, from)
}
Expand Down
77 changes: 76 additions & 1 deletion guides/client/js-interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,4 +314,79 @@ Hooks.Chart = {
*Note*: remember events pushed from the server via `push_event` are global and will be dispatched
to all active hooks on the client who are handling that event.

*Note*: In case a LiveView pushes events and renders content, `handleEvent` callbacks are invoked after the page is updated. Therefore, if the LiveView redirects at the same time it pushes events, callbacks won't be invoked on the old page's elements. Callbacks would be invoked on the redirected page's newly mounted hook elements.
*Note*: In case a LiveView pushes events and renders content, `handleEvent` callbacks are invokedafter the page is updated. Therefore, if the LiveView redirects at the same time it pushes events, callbacks won't be invoked on the old page's elements. Callbacks would be invoked on the redirected page's newly mounted hook elements.

## Navigation Guards

Sometimes, it is useful to perform an action before a navigation event occurs.
For instance, you may want to prompt the user to save their work before navigating
away from a page. The LiveSocket constructor accepts a navigation option with two callbacks:

* `beforeEach(to, from)` is called before a navigation event occurs.
If this callback returns `false`, the navigation event is cancelled.
The callback can return a promise, which will be awaited before
allowing the navigation to proceed.
* `afterEach(to, from)` is called after a navigation event is complete.
The return value of this callback is ignored. Its primary use is
for restoring state that was stored in `beforeEach`,
e.g., restoring the scroll position of custom containers.

For example, the following option could be used to prevent navigating away from a page
when there is a form with the `data-submit-pending` attribute:

```javascript
beforeEach(){
// prevent navigating when form submit is pending
if (document.querySelector("form[data-submit-pending]")) {
return confirm("Do you really want to leave with unsubmitted changes?")
}
}
```

This assumes that you are setting `data-submit-pending` on the form, for example,
when handling a `phx-change` event.

For this use case, you should also add a [`beforeunload` event listener](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event)
to the window to prevent the page from being closed (which does not trigger a navigation event):

```javascript
window.onbeforeunload = function(e) {
if(document.querySelector("form[data-submit-pending]")){
return "Do you really want to leave with unsubmitted changes?"
}
}
```

Another use case for navigation guards is to store and restore the scroll
position of custom scrollable containers. By default, LiveView only
restores the scroll position of the window itself.
If you have a custom scrollable container, you can use navigation guards like this:

```javascript
// app.js

let scrollPositions = {}

let liveSocket = new LiveSocket("/live", Socket, {
// other options left out
// ...
navigation: {
beforeEach() {
Array.from(document.querySelectorAll("[data-restore-scroll]")).forEach(el => {
scrollPositions[el.id] = el.scrollTop
})
},
afterEach() {
// restore scroll positions
Array.from(document.querySelectorAll("[data-restore-scroll]")).forEach(el => {
if(scrollPositions[el.id]) {
el.scrollTop = scrollPositions[el.id]
}
})
}
}
})
```

This assumes that all scrollable containers have a `data-restore-scroll` attribute,
as well as a unique `id`.

0 comments on commit 490ed31

Please sign in to comment.