How to block user from leaving a page on a single page app
August 04, 2020
In the event you don’t understand this article, here’s the talk, here’s the slides I used for the talk and here’s the demo I created to understand this better.
I first stumbled on this problem when I was creating a page containing forms in our internal portals. One of our requirements for a page containing forms is that clients should see a popup to inform them that they are leaving the page. The popup should allow them to select whether to leave or stay on the page. This requirement was implemented in our older internal portals which was the traditional web page where each page is a new page sent by the server. So, it seemed rather obvious that we should be able to implement it in our new portal.
Our new internal portal however, was designed as a SPA (Single Page Application) and was built with React, with Reach Router as our choice of router. It was unfortunate that Reach Router does not support this feature, which is coined by them as History blocking.
Nevertheless, I did some digging, only to find that history blocking was never fully working in the earlier versions. The reasons given was as follows, of which I cannot make sense of then:
- there is no way we the developers can prevent the user from changing the URL, so we have to try and fake it
- when the URL changes, we fire all the
history.block
callbacks- if any one of them prevents navigation (e.g. return false), try to revert the URL change
- in conclusion, there is no way to revert the URL change because there is no API in the browser to tell you the last URL you were at. It is a security measure by the browsers
And now its possible. Why? What changed?
Before we move on, we need to understand the following:
- Routing on SPA vs Traditional web page routing
- History API
- window.popstate event
- history blocking strategy from React Router
Routing
Traditional pages serve up a new page on every navigation. If you check your network tab in your browser, on navigation, you would see a new HTML page and its resources being sent. This new page loads a new document every time. Therefore, traditional web pages can use the beforeunload event to provide us with the feature we want. Also, note that this event is cancellable. That means developers can use event.preventDefault()
to prevent the event from happening.
SPA on the other hand have to implement their own routing. On clicking a link (or navigation actions), SPA should not make a request to the server to fetch a new fresh HTML page. The URL can change but the document should stay the same.
- So previously in traditional web page, 1 URL would usually be tied to a single page and the routing is handled on the server side. (Let’s disregard the hash on URLs)
- With SPA, routing is handled on the client side by the SPA itself. On matching a route, it will trigger the rerender of the app. This means that your URL and document are now independent of of each other and your SPA has to synchronise them to mimic traditional web pages behaviour
History API
So how does the SPA implement routing? It does it with the help of this History API. Why can’t you use the Location API location.href
to navigate the page? It will cause the browser to navigate to a new location. It also makes a new HTTP request.
The history api allows us to:
- reuse the active document
- update the URL and also simultaneously add this new URL location into the browser history session
- store a state with a URL location
- call the browser’s back or forward action or go to any history session entry in the history session stack via
go()
However, it has a few limitations:
- You cannot manipulate the history object
- You will not know the previous locations, despite having the means to traverse to them
Popstate event
beforeunload
event does not work on SPA “pages”. But there is a similar event for SPA and that is popstate event. Popstate event is triggered by any of the following:
- A browser action such as clicking the forward button
- A browser action such as clicking the back button
- Calling
history.back()
- Calling
history.forward()
Also, popstate is fired only when the user navigates between 2 history entries for the same document; popstate event is not cancellable.
The MDN web document has a section of when popstate event is fired in the sequence of events and here is a summary of what we need to note:
- On click of browser button action
- New location is loaded; This means URL will change and display the next location
- popstate event is sent
We can see that the URL location changed before popstate event is sent. Therefore, within the popstate event listener, we can expect the URL location to be the different from that of the current page.
History blocking strategy from React Router
Update: 8 August 2020: There are 2 ways history blocking will activate. The first is an in-app redirection; when you click on a redirect link within the SPA. The second is when you click on a browser back or forward button. An in-app redirection can be handled by checking if the new location is a blocked location. However the second redirection has to be caught and handled with popstate event handler; This section explains the history blocking strategy of the second redirection method.
As popstate
event cannot be cancelled, history blocking is done as follows:
- On page load, instantiate an array. This array will be used to track the history location. Let the first page we are at be index 0.
- When
popstate
event occured, the URL location would have changed to the new location, even when the document hasn’t. We get the new URL location state and give itindex = index + 1
. - From this array of index, we find
delta
, wherebydelta = currentPageIndex - redirectedPageIndex
. The redirected page refers to the page that you have landed on when a browser action is clicked. We shall use the term redirectedPageIndex to refer to thehistory.state
index we store on this redirected page. Take note that we will always land on this redirected page because the URL location changed before popstate event is sent.
Here is the confusing part:
If it is a back action, delta
would be positive because the currentPageIndex would be larger than the redirectedPageIndex. Remember that the URL location changed so we are calling go()
from the redirected page. Hence, we have to go back to current page so that the user still sees the right URL and we can display the history blocking popup. Therefore, delta is positive because it is inverted to oppose the change; go(delta)
allows us to go back to the current page and give the illusion that we are pending on the user’s decision - to redirect or not.
- The user will then see the popup dialog on the current page. If the user clicks cancel, they will remain on the page. If they choose to leave, we will call
go(delta * -1)
. The-1
signifies the inverse. That is,go(delta)
is called from the redirected page to reach the current page. So from current page, it has to call the inverse, which isdelta * -1
to go back to the redirected page.
Why was history blocking previously concluded as impossible?
In popstate event listener, the URL would have already changed to the new location. We will need to change the URL location back to that of the current location but we have no way of doing so because we can’t tell which browser action caused popstate event to be sent; popstate event doesn’t tell us whether the back button or forward button triggered it.
To tackle the issue of we don’t know whether the back button or forward button triggered popstate, the creator(s) of react router first tried to solve this by creating an array that stores the location the user has visited. From there, react router can then calculate where the new location is and how to get back to the current location.
But this solution has a drawback. This array is instantiated when createBrowserHistory()
is called. And createBrowserHistory()
is called when the SPA is loaded. This means that on refresh, the array would start anew too.
In earlier versions of react router (around 2015), this array was stored in session storage. However, this caused issues in Safari’s incognito mode. Apparently, in Safari’s private browsing mode, localsession and sessionstorage are both unavailable then. Safari has since fixed the bug in 2016, but react router has already moved on to their next approach.
If react router were to switch back to using session storage, it would be constrained by session storage space. As brought up by rpedela in this issue,
if the oldest entries are removed and the user navigates to a history entry not stored in session storage, what will happen?
I think the algorithm would run its path and see this revisited history entry as a new history entry; it would then add this into session storage. This would then cause a bug because the session storage will no longer be synchronised with history session.
Anyway, React router next approach was to storing the users location in the created history entry’s state. This is because when popstate event is fired, the state property of the popstate event will contain a copy of the history entry’s state object. But instead of instantiating an array, it uses history state as the array.
- Refresh issue? History state persists
- browser action? History state persists
What if we use location.href
within SPA? Or a manual URL redirect?
Case 1: When we use location.href
to navigate to 1 page in our SPA, and then continue to use react router’s navigation API
When this happens, a new document will be created for that page navigated to with location.href
and createBrowserHistory()
will run again. The history state will reset as though its the first time we enter the page.
When we go to a previous page with the browser back or forward button, the history state tied to that page would still exist. Hence, we can still calculate the delta
value.
Case 2: When we completely use location.href
to navigate within our SPA
If we were to traverse from one page of index 0 to another page of index 0, it is equivalent of traversing to 2 different documents and beforeunload
event will be fired instead of popstate
event. However, there is a caveat with beforeunload
event. The beforeunload dialog box doesn’t always appear even when beforeUnload handler is set up. This is because according to this section of HTML whatwg spec,
The user agent is encouraged to avoid asking the user for confirmation if it judges that doing so would be annoying, deceptive, or pointless. A simple heuristic might be that if the user has not interacted with the document, the user agent would not ask for confirmation before unloading it.
Here is a more concise explanation by chrome status: The beforeunload dialog will only be shown if the frame attempting to display it has received a user gesture or user interaction (or if any embedded frame has received such a gesture). (There will be no change to the dispatch of the beforeunload event, just a change to whether the dialog is shown.)
So we can block the user from leaving the page on a single page app?
Yes, if we keep to the following limitations:
- Navigate within the SPA with history library’s push instead of the browser’s history API (i.e.
window.history.pushState
) directly. This is because the history package useshistory.state
to keep track of its location and the history library’s push API handles this. - Try not to use
location.href
to navigate within the SPA. The beforeunload dialog box doesn’t always appear even when beforeunload handler is set up. This is a specified behaviour in HTML whatwg spec. - The browser must support history API.