HTML5 History API and Scrolling

Tuesday, Apr 3, 2012 7:22 pm
William Barnes

[Edit: The solution below has a bug. If the page is refreshed, then the states array is lost. I have an updated approach and will post it soon.]

My new web app uses the HTML5 History API (as abstracted by History.js) to smooth out navigation. This proved to be a bit of a challenge because, despite all the Javascript, my app is supposed to act just like a normal web page. The main content of my app is dynamically loaded in an ordinary static-positioned DIV. Thus, the user scrolls the window like normal. Other elements are fixed to the viewport.

One major problem I encountered was that browsers have serious issues with remembering the window scroll position across pages Normally, when a user clicks the back button, they are taken to the previous page at the same place that they left it. Most browsers don’t actually reload the page, but store it in memory, so this happens almost instantaneously. When using the History API, this does not always work. In this post, I detail the problems and my solution.

In Webkit, when the user navigates (using links or the back/forward buttons) the scroll does not change from page to page unless the page is too short to support the scroll value. If the user scrolls on page 1 and clicks to page 2, window.scrollY on page2 is the same as it was on page 1. If they scroll on page 2 and navigate back to page 1, scrollY is now the new value. If page 3 is not long enough to scroll, then scrollY is zeroed. Going back to page 1, it remains 0. I like this behaviour because it is predictable. If I am overriding the browser’s navigation, I’d like to have complete control.

Firefox, however, is somewhat erratic in this area. Generally speaking, it behaves like a browser on a normal website. If scrollY on page 1 is 50 and the user clicks to page 2, scrollY is 0. If they scroll a bit so scrollY on page 2 is now 200, then navigate back to page 1, scrollY will be 50. However, if they navigate to page 3 (which doesn’t scroll) and then back to page 1, scrollY will be 0.

I worked around this by storing the scroll value myself, and scrolling to that on each History statechange. Originally, I only stored the current scroll value while handling the statechange event, but Firefox applies its remembered value before firing the event. This placed me in the awkward position of needing to know the value before the user did anything. I use the window.onscroll event to detect when the window has been scrolled by the user so that it does not store a value in the awkward time where new content is loading. Following John Resig’s example, I store the value every half second using setInterval.

  1. var loc = {
  2. currentState: 0,
  3. states: [
  4. {scroll: 0}
  5. ],
  6. hasScrolled: true,
  7.  
  8. initHistory: function() {
  9. var History = baseWin.global.History;
  10. if (History.enabled) {
  11. // Before binding events, replace the state with the proper ID.
  12. // Otherwise, strange things happen.
  13. History.replaceState({state:0});
  14. // Bind the state change event (Dojo won't do this for some reason)
  15. History.Adapter.bind(baseWin.global, "statechange", loc.handleStateChange);
  16. // Bind the anchor click
  17. on(baseWin.body(), "a[href]:click", loc.anchorClick);
  18. // Bind the scroll event
  19. on(baseWin.global, 'scroll', function() { loc.hasScrolled = true; })
  20. // Store the scroll position every 500ms
  21. setInterval(function() {
  22. if (loc.hasScrolled) {
  23. loc.states[loc.currentState].scroll = baseWin.global.scrollY;
  24. loc.hasScrolled = false;
  25. }
  26. }, 500)
  27. }
  28. },
  29.  
  30. anchorClick: function(e) {
  31. var History = baseWin.global.History,
  32. rootUrl = History.getRootUrl(),
  33. node = e.target;
  34.  
  35. // Remove orphaned states
  36. loc.states = loc.states.slice(0, loc.currentState + 1);
  37. // Initialize the new state
  38. loc.states[loc.currentState + 1] = {scroll: 0};
  39.  
  40. // If there is a span, b, i, etc inside the link, that will be the target node.
  41. // This loop finds the actual anchor.
  42. while (node.nodeName.toLowerCase() != 'a') {
  43. node = node.parentNode;
  44. }
  45.  
  46. var url = domAttr.get(node,"href"),
  47. isInternalLink = url.substring(0,rootUrl.length) === rootUrl || url.indexOf(":") === -1;
  48. if (isInternalLink) {
  49. event.stop(e);
  50. url = url.replace(rootUrl,"")
  51. History.pushState({state:loc.currentState + 1},"",url);
  52. }
  53. },
  54.  
  55. handleStateChange: function(e) {
  56. var History = baseWin.global.History
  57. state = History.getState();
  58.  
  59. // Load the state id
  60. loc.currentState = state.data.state;
  61.  
  62. // IMPLEMENTATION SPECIFIC XHR STUFF
  63. // onSuccess: function() {
  64. baseWin.global.scrollTo(0, loc.states[loc.currentState].scroll);
  65. // }
  66. }
  67. }

Naturally, the above code would have to be modified for another application, but I hope that the method is clear and is of use to someone.