Skip to content

Commit

Permalink
This PR introduces a new option that can be specified when creating a
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
SteffenDE committed Jan 25, 2024
1 parent a774138 commit c034c38
Showing 1 changed file with 75 additions and 34 deletions.
109 changes: 75 additions & 34 deletions assets/js/phoenix_live_view/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export default class LiveSocket {
this.sessionStorage = opts.sessionStorage || window.sessionStorage
this.boundTopLevelEvents = false
this.domCallbacks = Object.assign({onNodeAdded: closure(), onBeforeElUpdated: closure()}, opts.dom || {})
this.navigationCallbacks = Object.assign({beforeEach: closure(), afterEach: closure()}, opts.navigation || {})
this.transitions = new TransitionSet()
window.addEventListener("pagehide", _e => {
this.unloaded = true
Expand Down Expand Up @@ -687,22 +688,36 @@ export default class LiveSocket {
}, 100)
})
window.addEventListener("popstate", event => {
const previousLocation = clone(this.currentLocation)
// early return if the navigation does not actually navigate anywhere (hashchange)
if(!this.registerNewLocation(window.location)){ return }
let {type, id, root, scroll} = event.state || {}
let href = window.location.href
this.withNavigationGuard(window.location.href, previousLocation.href, () => {
let {type, id, root, scroll} = event.state || {}
let href = window.location.href

DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: type === "patch", pop: true}})
this.requestDOMUpdate(() => {
if(this.main.isConnected() && (type === "patch" && id === this.main.id)){
this.main.pushLinkPatch(href, null, () => {
this.maybeScroll(scroll)
})
} else {
this.replaceMain(href, null, () => {
if(root){ this.replaceRootHistory() }
this.maybeScroll(scroll)
})
}
DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: type === "patch", pop: true}})
this.requestDOMUpdate(() => {
if(this.main.isConnected() && (type === "patch" && id === this.main.id)){
this.main.pushLinkPatch(href, null, () => {
this.maybeScroll(scroll)
this.afterNavigation(href, previousLocation.href)
})
} else {
this.replaceMain(href, null, () => {
if(root){ this.replaceRootHistory() }
this.maybeScroll(scroll)
this.afterNavigation(href, previousLocation.href)
})
}
})
}, () => {
// the navigation should be aborted
// because of the way the history api works, on a popstate we already
// navigated from the browser's point of view; therefore we need to
// push the previous location back to the history
Browser.pushState("push", history.state || {}, previousLocation.href)
// we also need to re-register the previous location to `this.currentLocation`
this.registerNewLocation(previousLocation)
})
}, false)
window.addEventListener("click", e => {
Expand Down Expand Up @@ -757,12 +772,14 @@ export default class LiveSocket {
}

pushHistoryPatch(href, linkState, targetEl){
if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href) }
this.withNavigationGuard(href, this.currentLocation.href, () => {
if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href) }

this.withPageLoading({to: href, kind: "patch"}, done => {
this.main.pushLinkPatch(href, targetEl, linkRef => {
this.historyPatch(href, linkState, linkRef)
done()
this.withPageLoading({to: href, kind: "patch"}, done => {
this.main.pushLinkPatch(href, targetEl, linkRef => {
this.historyPatch(href, linkState, linkRef)
done()
})
})
})
}
Expand All @@ -772,26 +789,32 @@ export default class LiveSocket {

Browser.pushState(linkState, {type: "patch", id: this.main.id}, href)
DOM.dispatchEvent(window, "phx:navigate", {detail: {patch: true, href, pop: false}})
const previousLocation = clone(this.currentLocation)
this.registerNewLocation(window.location)
this.afterNavigation(window.location.href, previousLocation.href)
}

historyRedirect(href, linkState, flash){
if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href, flash) }
this.withNavigationGuard(href, this.currentLocation.href, () => {
if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href, flash) }

// convert to full href if only path prefix
if(/^\/$|^\/[^\/]+.*$/.test(href)){
let {protocol, host} = window.location
href = `${protocol}//${host}${href}`
}
let scroll = window.scrollY
this.withPageLoading({to: href, kind: "redirect"}, done => {
this.replaceMain(href, flash, (linkRef) => {
if(linkRef === this.linkRef){
Browser.pushState(linkState, {type: "redirect", id: this.main.id, scroll: scroll}, href)
DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: false, pop: false}})
this.registerNewLocation(window.location)
}
done()
// convert to full href if only path prefix
if(/^\/$|^\/[^\/]+.*$/.test(href)){
let {protocol, host} = window.location
href = `${protocol}//${host}${href}`
}
let scroll = window.scrollY
this.withPageLoading({to: href, kind: "redirect"}, done => {
this.replaceMain(href, flash, (linkRef) => {
if(linkRef === this.linkRef){
Browser.pushState(linkState, {type: "redirect", id: this.main.id, scroll: scroll}, href)
DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: false, pop: false}})
const previousLocation = clone(this.currentLocation)
this.registerNewLocation(window.location)
this.afterNavigation(window.location.href, previousLocation.href)
}
done()
})
})
})
}
Expand All @@ -810,6 +833,24 @@ export default class LiveSocket {
}
}

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
// every other value proceeds with the navigation
const guardResult = this.navigationCallbacks["beforeEach"](to, from)
Promise.resolve(guardResult).then(result => {
if(result === false){
cancel()
} else {
callback()
}
})
}

afterNavigation(to, from){
this.navigationCallbacks["afterEach"](to, from)
}

bindForms(){
let iterations = 0
let externalFormSubmitted = false
Expand Down

0 comments on commit c034c38

Please sign in to comment.