3/10/2018Time to read: 6 min
I have been working on the routing system of our single page app for the last couple months. Right now I am working on refactoring the routing histories. I thought this would be a very common problem that large single page apps face, but I don't see a lot of people talking about it online. I am going to share my findings so far. This is by no mean a complete solution, just some food for thought.
Imaging an online shopping site and we are given the requirement to provide links to go back to the previous page. If a user navigates from the category page to checkout page, we should display a link "Back to category"
. If a user navigates from the product page to checkout page, we should display a link "Back to product"
.
How do we know where the user came from? In the old days, when routing is solely handled by a server, the server looks at where the request is coming from, and the page it is navigating to. With the rise of client-side app, we have HTML5 History API which provides very limited access to browser's history due to security concern.
window.history.back(); // equivalent to clicking browser back button
window.history.forward(); // equivalent to clicking browser forward button
window.history.pushState(data, null, url); // push a new URL without reloading the page
window.history.replaceState(data, null, url); // replace the current URL without reloading the page.
window.addEventListener('popstate', function(event) {
// triggered when user click on back/forward button or when history.back() or history.forward() is called
});
Does History API tell where a user came from? Unfortunately, no. This is due to security concern, since the browser history stack is shared among different domains. Say a user navigates from amazon.com
to google.com
, the JavaScript on google.com
should not be able to know that user came from amazon.com
.
The how exactly can we know where a user came from in our own domain?
We have to keep track of the history every time it's modified. When a user navigates to a new page, we save a record in our own history stack. We pop a record in our history stack when browser pop it.
This means two thing:
pushState
or replaceState
and prevent the default behavior.Those are not simple tasks!
I don't have a simpler solution. Please let me know through email or comment (when I finally implement commenting on this blog) if you know a better solution.
Alright, let's do the simplest part first: implement a history stack.
The most intuitive solution would be using an array.
const historyStack = [];
// when pushState
window.history.pushState(state, null, url);
historyStack.push({state, url});
// when replaceState
window.history.replaceState(state, null, url);
historyStack.pop();
historyStack.push({state, url});
A more sophisticated approach would be using a single linked list.
class HistoryNode {
constructor(url, prevNode, state) {
this.url = url;
this.prevNode = prevNode;
this.state = state;
}
}
const historyStack = { lastNode: undefined };
// when pushState
window.history.pushState(state, null, url);
historyStack.lastNode = new HistoryNode(url, historyStack.lastNode, state);
// when replaceState
window.history.replaceState(state, null, url);
historyStack.lastNode = new HistoryNode(url, historyStack.lastNode? historyStack.lastNode.lastNode : undefined, state);
With our own history stack, we can access any previous locations and figure out where a user comes from.
if (historyStack.lastNode.url.contains('/category')) {
renderBackLink('Back to category');
}
So we are pushing new records to our history stack, when do we pop a record? we want to keep our history stack consistent with browser's history stack, don't we?
We can't do it easily. When a popstate
happens, we don't know if a user clicks on the back button or the forward button. We don't know if window.history.back()
, window.history.forward()
or window.history.go()
triggered it. To make it even worse, if we have an anchor tag that doesn't have a click event handler, such as:
<a href="#checkout">Check out</a>
Guess what, clicking on it will trigger a popstate
event!
We can, as the answer in this StackOverflow question suggests, keep an id in the state when we push or replace,
window.history.pushState({id: '123'}, null, 'new-url');
...
// when navigating back to this page, we will receive a popstate event
assert(event.state.id === '123');
Then when we receive a popstate
event, we can look at the state property from the event object, and compare the id with the records in our history stack.
So say we find out that user uses either back button or history.back()
and come back to a page using the id strategy. We know which record in our historyStack represents the current location, do we pop everything after this record? No.
We have to be prepared for the forward button click. When forward button is clicked, we still receive a 'popstate' event, but now we need to look for the corresponding record in our historyStack after the current record.
This means the current location doesn't need to be at the top of the stack. For array implementation of the history stack, that means an extra pointer denoting the current position in the stack. For linked list implementation of the history stack, that means having an extra head node representing the current record.
So when do we actually pop a state from our historyStack?
When we pushState
. This might be counter-intuitive, but it actually make sense.
If we see the browser history as a thread of different locations, and the user is on one of the locations: When we pushState, we create a branch on the linear thread, and the original branch become useless and should be discarded.
What about replaceState
?
replaceState
replace the current node in the thread.
We can still go forward when we replaceState
.
When we click on the refresh button in the browser, Boom, our history stack is gone, because it is saved in JavaScript scope, but hey, the browser's history is still there. Now we don't know anything about the history anymore. Fine, let's store the history stack in sessionStorage whenever we change it and restore it when browser refreshes:
// saving
window.sessionStorage.setItem('HISTORY_STACK', JSON.stringify(historyStack));
// restore
const historyString = window.sessionStorage.getItem('HISTORY_STACK');
historyStack = historyString ? JSON.parse(historyString) : [];
In some browsers, you can right-click on the title of a page and Duplicate
. The browser will open the same page in another tab and makes a copy of the native history stack. The session storage is also copied too, so we restore our historyStack from sessionStorage in the new tab.
I apologize for not giving you a simpler solution to this problem. Reimplementing something browser natively supports is hard. Working with different browser behaviors is harder. The point of this post is to share some of the struggles I went through in implementing a history stack in order to maintain routing history so that you have a better understanding of the limitation when you need to do the same thing.