Skip to content

Commit

Permalink
[changed] Replace location objects with history
Browse files Browse the repository at this point in the history
Please note: this commit is still a work in progress!

All history objects subclass History and support 2 main methods:

- pushState(state, path)
- replaceState(state, path)

This API more closely matches the HTML5 history API, with the notable
omission of the title argument which is currently ignored in all major
browsers. It provides the user with the ability to store state specific
to the current invocation of the current URL without storing that data
in the URL itself. However, history objects that do not use the HTML5
history API (HashHistory and RefreshHistory) store their state ID in
the query string.

This should help with #767 and #828.

This work was inspired by work done by @taurose in #843 and @insin
in #828.
  • Loading branch information
mjackson committed Feb 27, 2015
1 parent cfff382 commit 212b9d5
Show file tree
Hide file tree
Showing 24 changed files with 579 additions and 417 deletions.
75 changes: 75 additions & 0 deletions modules/DOMUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
var PathUtils = require('./PathUtils');

var STATE_KEY_QUERY_PARAM = '_sk';

function getHashPath() {
return decodeURI(
// We can't use window.location.hash here because it's not
// consistent across browsers - Firefox will pre-decode it!
window.location.href.split('#')[1] || ''
);
}

function getWindowPath() {
return decodeURI(
window.location.pathname + window.location.search
);
}

function getState(path) {
var stateID = getStateID(path);
var serializedState = stateID && window.sessionStorage.getItem(stateID);
return serializedState ? JSON.parse(serializedState) : null;
}

function getStateID(path) {
var query = PathUtils.extractQuery(path);
return query && query[STATE_KEY_QUERY_PARAM];
}

function withStateID(path, stateID) {
var query = Path.extractQuery(path) || {};
query[STATE_KEY_QUERY_PARAM] = stateID;
return PathUtils.withQuery(PathUtils.withoutQuery(path), query);
}

function withoutStateID(path) {
var query = PathUtils.extractQuery(path);

if (STATE_KEY_QUERY_PARAM in query) {
delete query[STATE_KEY_QUERY_PARAM];
return PathUtils.withQuery(PathUtils.withoutQuery(path), query);
}

return path;
}

function saveState(state) {
var stateID = state.id;

if (stateID == null)
stateID = state.id = Math.random().toString(36).slice(2);

window.sessionStorage.setItem(
stateID,
JSON.stringify(state)
);

return stateID;
}

function withState(path, state) {
var stateID = state != null && saveState(state);
return stateID ? withStateID(path, stateID) : withoutStateID(path);
}

module.exports = {
getHashPath,
getWindowPath,
getState,
getStateID,
withStateID,
withoutStateID,
saveState,
withState
};
31 changes: 0 additions & 31 deletions modules/History.js

This file was deleted.

10 changes: 10 additions & 0 deletions modules/Location.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class Location {

constructor(path, state=null) {
this.path = path;
this.state = state;
}

}

module.exports = Location;
File renamed without changes.
39 changes: 39 additions & 0 deletions modules/history/DOMHistory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
var invariant = require('react/lib/invariant');
var History = require('./History');

class DOMHistory extends History {

get length() {
var state = this.getCurrentState();
return state && state.length || 1;
}

get current() {
var state = this.getCurrentState();
return state && state.current || this.length - 1;
}

canGo(n) {
if (n === 0)
return true;

var next = this.current + n;
return next >= 0 && next < this.length;
}

go(n) {
if (n === 0)
return;

invariant(
this.canGo(n),
'Cannot go(%s); there is not enough history',
n
);

window.history.go(n);
}

}

module.exports = DOMHistory;
69 changes: 69 additions & 0 deletions modules/history/HTML5History.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/* jshint -W058 */
var assign = require('react/lib/Object.assign');
var LocationActions = require('../LocationActions');
var { getWindowPath } = require('../DOMUtils');
var DOMHistory = require('./DOMHistory');

var isListening = false;

function onPopState(event) {
if (event.state === undefined)
return; // Ignore extraneous popstate events in WebKit.

HTML5History.notifyChange(LocationActions.POP);
}

/**
* A history implementation for DOM environments that support the
* HTML5 history API (pushState and replaceState). Provides the cleanest
* URLs. This implementation should always be used if possible.
*/
var HTML5History = assign(new DOMHistory, {

addChangeListener(listener) {
DOMHistory.prototype.addChangeListener.call(this, listener);

if (!isListening) {
if (window.addEventListener) {
window.addEventListener('popstate', onPopState, false);
} else {
window.attachEvent('onpopstate', onPopState);
}

isListening = true;
}
},

removeChangeListener(listener) {
DOMHistory.prototype.removeChangeListener.call(this, listener);

if (this.changeListeners.length === 0) {
if (window.addEventListener) {
window.removeEventListener('popstate', onPopState, false);
} else {
window.removeEvent('onpopstate', onPopState);
}

isListening = false;
}
},

pushState(state, path) {
window.history.pushState(state, '', path);
this.notifyChange(LocationActions.PUSH);
},

replaceState(state, path) {
window.history.replaceState(state, '', path);
this.notifyChange(LocationActions.REPLACE);
},

getCurrentPath: getWindowPath,

getCurrentState() {
return window.history.state;
}

});

module.exports = HTML5History;
92 changes: 92 additions & 0 deletions modules/history/HashHistory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/* jshint -W058 */
var assign = require('react/lib/Object.assign');
var LocationActions = require('../LocationActions');
var { getHashPath, withState, withoutStateID, getState } = require('../DOMUtils');
var DOMHistory = require('./DOMHistory');

var currentLocationAction;
var isListening = false;

function ensureSlash() {
var path = HashHistory.getCurrentPath();

if (path.charAt(0) === '/')
return true;

HashHistory.replace('/' + path);

return false;
}

function onHashChange() {
if (ensureSlash()) {
HashHistory.notifyChange(
currentLocationAction || LocationActions.POP
);

currentLocationAction = null;
}
}

/**
* A history implementation for DOM environments that uses window.location.hash
* to store the current path. This is a hack for older browsers that do not
* support the HTML5 history API (IE <= 9). It is currently used as the
* default in DOM environments because it offers the widest range of support.
*/
var HashHistory = assign(new DOMHistory, {

addChangeListener(listener) {
DOMHistory.prototype.addChangeListener.call(this, listener);

// Do this BEFORE listening for hashchange.
ensureSlash();

if (!isListening) {
if (window.addEventListener) {
window.addEventListener('hashchange', onHashChange, false);
} else {
window.attachEvent('onhashchange', onHashChange);
}

isListening = true;
}
},

removeChangeListener(listener) {
DOMHistory.prototype.removeChangeListener.call(this, listener);

if (this.changeListeners.length === 0) {
if (window.removeEventListener) {
window.removeEventListener('hashchange', onHashChange, false);
} else {
window.removeEvent('onhashchange', onHashChange);
}

isListening = false;
}
},

pushState(state, path) {
currentLocationAction = LocationActions.PUSH;
window.location.hash = withState(path, state);
},

replaceState(state, path) {
currentLocationAction = LocationActions.REPLACE;
window.location.replace(
window.location.pathname + window.location.search + '#' + withState(path, state)
);
},

getCurrentPath() {
return withoutStateID(getHashPath());
},

getCurrentState() {
return getState(getHashPath());
}

});

module.exports = HashHistory;
68 changes: 68 additions & 0 deletions modules/history/History.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
var Location = require('../Location');

/**
* An abstract base class for history objects. Requires subclasses
* to implement the following properties/methods:
*
* - length
* - pushState(state, path)
* - replaceState(state, path)
* - getCurrentPath()
* - getCurrentState()
* - go(n)
*/
class History {

addChangeListener(listener) {
if (!this.changeListeners)
this.changeListeners = [];

this.changeListeners.push(listener);
}

removeChangeListener(listener) {
if (!this.changeListeners)
return;

this.changeListeners = this.changeListeners.filter(function (li) {
return li !== listener;
});
}

notifyChange(changeType) {
if (!this.changeListeners)
return;

var location = this.getCurrentLocation();

for (var i = 0, len = this.changeListeners.length; i < len; ++i)
this.changeListeners[i].call(this, location, changeType);
}

getCurrentLocation() {
return new Location(this.getCurrentPath(), this.getCurrentState());
}

canGo(n) {
return n === 0;
}

canGoBack() {
return this.canGo(-1);
}

canGoForward() {
return this.canGo(1);
}

back() {
this.go(-1);
}

forward() {
this.go(1);
}

}

module.exports = History;
Loading

0 comments on commit 212b9d5

Please sign in to comment.