 README.md         |   8 ++
 js/refreshless.js | 251 ++++++++++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 222 insertions(+), 37 deletions(-)

diff --git a/README.md b/README.md
index e631309..486214c 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,14 @@
 - ‘Back’ and ‘Forward’ buttons in browser are broken.
 
 
+# How RefreshLess uses the History API
+
+- Every URL transition within Drupal is tracked: both inter-page navigation and intra-page (fragment) navigation. See the `State` object.
+- Every `State` object has a position.
+- The current position in the history stack is tracked. This allows us to detect whether the user is navigating backward or forward.
+- Scroll restoration is handled entirely by History API, hence we don't need to track the scroll position at all
+
+
 # Requirements
 
 - Theme always has the same layout (e.g. no conditional `<body>` classes based on current path/route).
diff --git a/js/refreshless.js b/js/refreshless.js
index b3576d9..7d8801f 100644
--- a/js/refreshless.js
+++ b/js/refreshless.js
@@ -2,7 +2,139 @@
 
   'use strict';
 
-  var relativeLinksSelector = 'a[href^="/"]';
+  var relativeLinksSelector = 'a[href^="/"], a[href^="#"]';
+
+  // Tracks the current position in the History API.
+  // @todo Verify that this can work together with other JS using hash-based navigation.
+  var currentPos = null;
+
+  /**
+   * @typedef {object} Url
+   *
+   * @prop {string} absoluteUrl
+   *   The absolute URL, including scheme, host, path, query string, fragment.
+   * @prop {string} requestUrl
+   *   The URL minus the fragment, if any. To use in a request to the server.
+   * @prop {?string} [fragment]
+   *   The fragment of the URL, if any. Without the '#' character.
+   */
+
+  /**
+   * Url constructor.
+   *
+   * @constructor
+   *
+   * @param {string} url
+   *   A URL: can be an absolute URL, a relative URL, a fragment URL: any URL.
+   */
+  function Url(url) {
+    var fragmentLength, link;
+    link = document.createElement("a");
+    link.href = url;
+    fragmentLength = link.hash.length;
+    this.absoluteUrl = link.href;
+    if (fragmentLength < 2) {
+      this.requestUrl = this.absoluteUrl;
+    } else {
+      this.requestUrl = this.absoluteUrl.slice(0, -fragmentLength);
+      this.fragment = link.hash.slice(1);
+    }
+  }
+
+  /**
+   * @typedef {object} State
+   *
+   * @prop {number} pos
+   *   The position in the history. Non-negative integer.
+   * @prop {string} type
+   *   One of `'inter'` (inter-page navigation: requires talking to server) or
+   *   `'intra'` (intra-page navigation: requires no talking to server).
+   * @prop {string} absoluteUrl
+   *   {@link Drupal.Ajax#absoluteUrl}
+   * @prop {string} requestUrl
+   *   {@link Drupal.Ajax#requestUrl}
+   * @prop {string|bool}
+   *   {@link Drupal.Ajax#fragment} if set, otherwise `false`.
+   * @prop {object|null} content
+   *   If type `'inter'`, then an object with the data representing the content,
+   *   otherwise `null`.
+   *
+   * For state management. To be stored in History API's "state" entries.
+   *
+   * Guaranteed to be smaller than 640K, to ensure a fit.
+   * @see https://developer.mozilla.org/en-US/docs/Web/API/History_API
+   *
+   * For example, when this is the navigation history:
+   * 1. /foo
+   * 2. /bar
+   * 3. /bar#llama
+   * 4. /bar#alpaca
+   * 5. /baz
+   *
+   * Then these are the stored state changes:
+   * - 1 -> 2 = inter (stored in Drupal.refreshless.state.2)
+   * - 2 -> 3 = intra (stored in Drupal.refreshless.state.3)
+   * - 3 -> 4 = intra (stored in Drupal.refreshless.state.4)
+   * - 4 -> 5 = inter (stored in Drupal.refreshless.state.5)
+   *
+   * And if navigating back can be somewhat tricky:
+   * - Going back from 3 to 2, or 4 to 3 are both 'intra' changes: the content
+   *   remains the same.
+   * - Going back from 5 to 4 is an 'inter', but cannot use 5's `State`, since
+   *   that itself was merely an 'intra'. We need to go back through all
+   *   consecutive 'intra's until we encounter an inter (2), and apply those
+   *   HTML-changing AJAX commands, and *then* perform the 'intra' navigation.
+   * - In other words: each history state entry records data and its type in
+   *   relation to the previous state in the sequence. When going backwards,
+   *   that requires the extra care just explained.
+   */
+  function State(historyPosition, type, navigationUrl) {
+    this.pos = historyPosition;
+    this.type = type;
+    var url = new Url(navigationUrl);
+    this.absoluteUrl = url.absoluteUrl;
+    this.requestUrl = url.requestUrl;
+    this.fragment = url.fragment ? url.fragment : false;
+    // For type = inter only.
+    this.content = null;
+  }
+  State.prototype.getId = function() {
+    return State.getIdForPos(this.pos);
+  };
+  State.prototype.getType = function() {
+    return this.type;
+  };
+  State.prototype.store = function(method) {
+    storage.setItem(this.getId(), JSON.stringify(this));
+    history[method + 'State'](this.getId(), null, this.absoluteUrl);
+  };
+  /**
+   * Finds the `type=inter` state to restore when navigating back.
+   *
+   * @returns {State}
+   *   The `State` of `type='inter'` to restore.
+   */
+  State.prototype.findInterState = function() {
+    var state = this;
+    while (state.type !== 'inter') {
+      state = State.fromPos(state.pos - 1);
+    }
+    return state;
+  };
+  State.getIdForPos = function(pos) {
+    return 'Drupal.refreshless.state.' + pos;
+  };
+  State.isValidId = function(id) {
+    return typeof id === 'string' && id.substr(0, 24) === 'Drupal.refreshless.state';
+  };
+  State.fromId = function(id) {
+    var state = JSON.parse(storage.getItem(id));
+    state.__proto__ = State.prototype;
+    return state;
+  };
+  State.fromPos = function(pos) {
+    return State.fromId(State.getIdForPos(pos));
+  };
 
   function getLibraries() {
     return drupalSettings.ajaxPageState.libraries.split(',');
@@ -23,19 +155,19 @@
     return drupalSettings.path.currentPathIsAdmin != urlIsAdmin;
   }
 
-  function debug(isNewHistory) {
+  function debugInterState(isNewHistory) {
     var url = drupalSettings.path.currentPath;
-    var state = JSON.parse(storage.getItem(history.state));
-    var replacedRegions = state.htmlChangingCommands.filter(function (command) { return command.command === 'refreshlessUpdateRegion' });
+    var state = State.fromId(history.state).findInterState();
+    var replacedRegions = state.content.htmlChangingCommands.filter(function (command) { return command.command === 'refreshlessUpdateRegion' });
     if (isNewHistory) {
-      console.debug('Received Refreshless response for "' + url + '".');
+      console.debug('Updated HTML using the received Refreshless response for "' + url + '".');
     }
     else {
-      console.debug('Navigated to "' + url + '" using state stored in History API.');
+      console.debug('Updated HTML without performing a request: used state stored in History API.');
     }
     console.debug('  Replaced ' + replacedRegions.length + ' out of ' + document.querySelectorAll('[data-refreshless-region]').length + ' regions (' + replacedRegions.map(function (command) { return command.region; }).join(', ') + ').');
     if (isNewHistory) {
-      console.debug('  Loaded ' + state.loadedLibraries.length + ' additional asset libraries: ', state.loadedLibraries);
+      console.debug('  Loaded ' + state.content.loadedLibraries.length + ' additional asset libraries: ', state.content.loadedLibraries);
     }
   }
 
@@ -98,15 +230,19 @@
       return;
     }
 
-    var url = this.getAttribute('href');
-    if (urlUsesDifferentTheme(url)) {
+    var current = new Url(location);
+    var target = new Url(this.href);
+
+    // When navigating to a fragment, or navigating to a URL which has a
+    // different theme, let the browser handle it.
+    if (current.requestUrl === target.requestUrl || urlUsesDifferentTheme(target.requestUrl)) {
       return;
     }
 
     if (!event.isDefaultPrevented()) {
       event.preventDefault();
 
-      var ajaxObject = createAjaxObject(url);
+      var ajaxObject = createAjaxObject(target.requestUrl);
 
       // When the Refreshless request receives a succesful response, update the
       // URL using the history.pushState() API.
@@ -115,10 +251,12 @@
         // When navigating forward from the first page load on this Drupal site,
         // build the inverse state to be able to invert the changes when
         // navigating back.
-        if (history.state === 'Drupal.refreshless.root') {
+        // @todo verify this works when the first navigation is to a fragment
+        if (State.isValidId(history.state) && history.state === State.getIdForPos(storage.getItem('Drupal.refreshless.root'))) {
+          var rootState = State.fromId(history.state);
           var inverseResponse = buildInverseResponse(response);
-          var inverseState = buildState([], inverseResponse, drupalSettings);
-          storage.setItem('Drupal.refreshless.root', JSON.stringify(inverseState));
+          rootState.content = buildPageStateContent([], inverseResponse, drupalSettings);
+          rootState.store('replace');
           console.log('Updated root state.');
         }
 
@@ -126,15 +264,17 @@
 
         originalSuccess.call(this, response, status, xmlhttprequest);
 
-        var state = buildState(librariesBefore, response, drupalSettings);
-
-        // History API has a 640K limit.
-        // @see https://developer.mozilla.org/en-US/docs/Web/API/History_API
-        var id = 'Drupal.refreshless.state.' + (history.length + 1);
-        storage.setItem(id, JSON.stringify(state));
-        history.pushState(id, state.title, url);
+        var state = new State(history.length, 'inter', target.absoluteUrl);
+        state.content = buildPageStateContent(librariesBefore, response, drupalSettings);
+        state.store('push');
+        currentPos++;
+        debugInterState(true);
 
-        debug(true);
+        // If we were navigating to a fragment on another page, then let the
+        // browser handle fragment navigation. This won't push new history.
+        if (target.fragment) {
+          location.assign('#' + target.fragment);
+        }
       };
 
       ajaxObject.execute();
@@ -144,7 +284,7 @@
   // librariesBefore: before response is applied
   // response: the response to apply
   // drupalSettings: after the response is applied
-  function buildState(librariesBefore, response, drupalSettings) {
+  function buildPageStateContent(librariesBefore, response, drupalSettings) {
     var updateHtmlHeadCommand = response.filter(function (command) { return command.command === 'refreshlessUpdateHtmlHead' })[0];
 
     return {
@@ -182,29 +322,66 @@
     return inverseResponse;
   }
 
-  history.replaceState('Drupal.refreshless.root', null, window.location);
+  currentPos = history.length - 1;
+
+  var root = storage.getItem('Drupal.refreshless.root');
+  if (root === null) {
+    root = currentPos;
+    storage.setItem('Drupal.refreshless.root', root);
+  }
+
+  if (root == currentPos) {
+    var rootState = new State(root, 'inter', window.location);
+    rootState.root = true;
+    rootState.store('replace');
+  }
 
   jQuery('body').once('refreshless').on('click', relativeLinksSelector, handleClick);
 
   window.onpopstate = function(event) {
-    if (!(typeof event.state === 'string' && event.state.substr(0, 19) === 'Drupal.refreshless.')) {
-      window.location.reload();
+    // Only react to PopState events that contain data that we know how to deal
+    // with. There can be other JavaScript (e.g. progressively decoupled
+    // components) on the page that also use the History API. This allows those
+    // to gracefully coexist.
+    if (!State.isValidId(event.state)) {
       return;
     }
 
-    var state = JSON.parse(storage.getItem(event.state));
+    var fromState = State.fromPos(currentPos);
+    var toState = State.fromId(event.state);
+    var direction = fromState.pos < toState.pos ? 'forward' : 'backward';
+    var type = direction === 'forward' ? toState.type : fromState.type;
+    currentPos = toState.pos;
+
+    console.debug('Navigated', direction, 'from', fromState.pos, 'to', toState.pos, 'type', type, '(' + fromState.absoluteUrl + ' → ' + toState.absoluteUrl + ')');
+
+    // We rely on History API's scrollRestoration=auto, so just ensure the right
+    // HTML is present: only deal with type=inter.
+    if (type === 'inter') {
+      var state = toState.findInterState();
+      // Simulate a Refreshless response having arrived, and let the Ajax system
+      // handle it.
+      var ajaxObject = createAjaxObject('');
+      var fakeResponse = state.content.htmlChangingCommands;
+      fakeResponse.unshift({
+        command: 'settings',
+        settings: state.content.drupalSettings,
+        merge: false
+      });
+      ajaxObject.success(fakeResponse, 'success');
+      debugInterState(false);
+    }
+  };
 
-    // Simulate a Refreshless response having arrived, and let the Ajax system
-    // handle it.
-    var ajaxObject = createAjaxObject('');
-    var fakeResponse = state.htmlChangingCommands;
-    fakeResponse.unshift({
-      command: 'settings',
-      settings: state.drupalSettings,
-      merge: false
-    });
-    ajaxObject.success(fakeResponse, 'success');
-    debug(false);
+  window.onhashchange = function (event) {
+    // Detect hash changes triggered by following local fragments: the browser
+    // will assign the `null` state. We want to replace that with our own state.
+    // @todo Verify that this can work together with other JS using hash-based navigation.
+    if (history.state === null) {
+      var state = new State(history.length - 1, 'intra', event.newURL);
+      state.store('replace');
+      currentPos++;
+    }
   };
 
   /**
