js/refreshless.js | 258 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 211 insertions(+), 47 deletions(-) diff --git a/js/refreshless.js b/js/refreshless.js index 2e8e149..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,23 +230,19 @@ return; } - var requestUrl = this.pathname; - var fragment = this.hash; + var current = new Url(location); + var target = new Url(this.href); - // When navigating to the same page, or to a fragment on the same page, let - // the browser handle it. - if (Drupal.url(drupalSettings.path.currentPath) === requestUrl) { - return; - } - - if (urlUsesDifferentTheme(requestUrl)) { + // 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(requestUrl); + var ajaxObject = createAjaxObject(target.requestUrl); // When the Refreshless request receives a succesful response, update the // URL using the history.pushState() API. @@ -123,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.'); } @@ -134,20 +264,16 @@ 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, requestUrl); - - debug(true); + var state = new State(history.length, 'inter', target.absoluteUrl); + state.content = buildPageStateContent(librariesBefore, response, drupalSettings); + state.store('push'); + currentPos++; + debugInterState(true); // If we were navigating to a fragment on another page, then let the - // browser handle fragment navigation. - if (fragment.length) { - location.assign(fragment); + // browser handle fragment navigation. This won't push new history. + if (target.fragment) { + location.assign('#' + target.fragment); } }; @@ -158,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 { @@ -196,28 +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.')) { + // 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++; + } }; /**