From 0e7a182b64ccd9613021fdb046da3b38d012d985 Mon Sep 17 00:00:00 2001
From: Sean Adkinson <sean.adkinson@gmail.com>
Date: Mon, 4 Aug 2014 17:15:52 -0700
Subject: [PATCH] [added] pluggable history implementations closes #166

---
 Location.js                         |   1 +
 index.js                            |   1 +
 modules/components/Routes.js        |  12 ++-
 modules/helpers/DisabledLocation.js |  34 ++++++++
 modules/helpers/HashLocation.js     |  60 +++++++++++++
 modules/helpers/HistoryLocation.js  |  50 +++++++++++
 modules/helpers/Location.js         |  10 +++
 modules/helpers/MemoryLocation.js   |  53 ++++++++++++
 modules/helpers/getWindowPath.js    |   9 ++
 modules/stores/URLStore.js          | 100 ++++------------------
 specs/URLStore.spec.js              | 128 ++++++++++++++++------------
 11 files changed, 318 insertions(+), 140 deletions(-)
 create mode 100644 Location.js
 create mode 100644 modules/helpers/DisabledLocation.js
 create mode 100644 modules/helpers/HashLocation.js
 create mode 100644 modules/helpers/HistoryLocation.js
 create mode 100644 modules/helpers/Location.js
 create mode 100644 modules/helpers/MemoryLocation.js
 create mode 100644 modules/helpers/getWindowPath.js

diff --git a/Location.js b/Location.js
new file mode 100644
index 00000000..76105d5d
--- /dev/null
+++ b/Location.js
@@ -0,0 +1 @@
+module.exports = require('./modules/helpers/Location');
diff --git a/index.js b/index.js
index 436d957f..379cff1e 100644
--- a/index.js
+++ b/index.js
@@ -4,6 +4,7 @@ exports.Link = require('./Link');
 exports.Redirect = require('./Redirect');
 exports.Route = require('./Route');
 exports.Routes = require('./Routes');
+exports.Location = require('./Location');
 exports.goBack = require('./goBack');
 exports.replaceWith = require('./replaceWith');
 exports.transitionTo = require('./transitionTo');
diff --git a/modules/components/Routes.js b/modules/components/Routes.js
index 902735ca..5054309f 100644
--- a/modules/components/Routes.js
+++ b/modules/components/Routes.js
@@ -5,6 +5,7 @@ var mergeProperties = require('../helpers/mergeProperties');
 var goBack = require('../helpers/goBack');
 var replaceWith = require('../helpers/replaceWith');
 var transitionTo = require('../helpers/transitionTo');
+var Location = require('../helpers/Location');
 var Route = require('../components/Route');
 var Path = require('../helpers/Path');
 var ActiveStore = require('../stores/ActiveStore');
@@ -53,8 +54,15 @@ var Routes = React.createClass({
   },
 
   propTypes: {
-    location: React.PropTypes.oneOf([ 'hash', 'history' ]).isRequired,
-    preserveScrollPosition: React.PropTypes.bool
+    preserveScrollPosition: React.PropTypes.bool,
+    location: function(props, propName, componentName) {
+      var location = props[propName];
+      if (!Location[location]) {
+        return new Error('No matching location: "' + location +
+          '".  Must be one of: ' + Object.keys(Location) +
+          '. See: ' + componentName);
+      }
+    }
   },
 
   getDefaultProps: function () {
diff --git a/modules/helpers/DisabledLocation.js b/modules/helpers/DisabledLocation.js
new file mode 100644
index 00000000..a7ba1fa0
--- /dev/null
+++ b/modules/helpers/DisabledLocation.js
@@ -0,0 +1,34 @@
+var getWindowPath = require('./getWindowPath');
+
+/**
+ * Location handler that doesn't actually do any location handling.  Instead, requests
+ * are sent to the server as normal.
+ */
+var DisabledLocation = {
+
+  type: 'disabled',
+
+  init: function() { },
+
+  destroy: function() { },
+
+  getCurrentPath: function() {
+    return getWindowPath();
+  },
+
+  push: function(path) {
+    window.location = path;
+  },
+
+  replace: function(path) {
+    window.location.replace(path);
+  },
+
+  back: function() {
+    window.history.back();
+  }
+
+};
+
+module.exports = DisabledLocation;
+
diff --git a/modules/helpers/HashLocation.js b/modules/helpers/HashLocation.js
new file mode 100644
index 00000000..954bfbbc
--- /dev/null
+++ b/modules/helpers/HashLocation.js
@@ -0,0 +1,60 @@
+var getWindowPath = require('./getWindowPath');
+
+function getWindowChangeEvent() {
+  return window.addEventListener ? 'hashchange' : 'onhashchange';
+}
+
+/**
+ * Location handler which uses the `window.location.hash` to push and replace URLs
+ */
+var HashLocation = {
+
+  type: 'hash',
+  onChange: null,
+
+  init: function(onChange) {
+    var changeEvent = getWindowChangeEvent();
+
+    if (window.location.hash === '') {
+      this.replace('/');
+    }
+
+    if (window.addEventListener) {
+      window.addEventListener(changeEvent, onChange, false);
+    } else {
+      window.attachEvent(changeEvent, onChange);
+    }
+
+    this.onChange = onChange;
+    onChange();
+  },
+
+  destroy: function() {
+    var changeEvent = getWindowChangeEvent();
+    if (window.removeEventListener) {
+      window.removeEventListener(changeEvent, this.onChange, false);
+    } else {
+      window.detachEvent(changeEvent, this.onChange);
+    }
+  },
+
+  getCurrentPath: function() {
+    return window.location.hash.substr(1);
+  },
+
+  push: function(path) {
+    window.location.hash = path;
+  },
+
+  replace: function(path) {
+    window.location.replace(getWindowPath() + '#' + path);
+  },
+
+  back: function() {
+    window.history.back();
+  }
+
+};
+
+module.exports = HashLocation;
+
diff --git a/modules/helpers/HistoryLocation.js b/modules/helpers/HistoryLocation.js
new file mode 100644
index 00000000..071e596a
--- /dev/null
+++ b/modules/helpers/HistoryLocation.js
@@ -0,0 +1,50 @@
+var getWindowPath = require('./getWindowPath');
+
+/**
+ * Location handler which uses the HTML5 History API to push and replace URLs
+ */
+var HistoryLocation = {
+
+  type: 'history',
+  onChange: null,
+
+  init: function(onChange) {
+    if (window.addEventListener) {
+      window.addEventListener('popstate', onChange, false);
+    } else {
+      window.attachEvent('popstate', onChange);
+    }
+    this.onChange = onChange;
+    onChange();
+  },
+
+  destroy: function() {
+    if (window.removeEventListener) {
+      window.removeEventListener('popstate', this.onChange, false);
+    } else {
+      window.detachEvent('popstate', this.onChange);
+    }
+  },
+
+  getCurrentPath: function() {
+    return getWindowPath();
+  },
+
+  push: function(path) {
+    window.history.pushState({ path: path }, '', path);
+    this.onChange();
+  },
+
+  replace: function(path) {
+    window.history.replaceState({ path: path }, '', path);
+    this.onChange();
+  },
+
+  back: function() {
+    window.history.back();
+  }
+
+};
+
+module.exports = HistoryLocation;
+
diff --git a/modules/helpers/Location.js b/modules/helpers/Location.js
new file mode 100644
index 00000000..751f822a
--- /dev/null
+++ b/modules/helpers/Location.js
@@ -0,0 +1,10 @@
+/**
+ * Map of location type to handler.
+ * @see Routes#location
+ */
+module.exports = {
+  hash: require('./HashLocation'),
+  history: require('./HistoryLocation'),
+  disabled: require('./DisabledLocation'),
+  memory: require('./MemoryLocation')
+};
\ No newline at end of file
diff --git a/modules/helpers/MemoryLocation.js b/modules/helpers/MemoryLocation.js
new file mode 100644
index 00000000..ee2e0ea6
--- /dev/null
+++ b/modules/helpers/MemoryLocation.js
@@ -0,0 +1,53 @@
+var invariant = require('react/lib/invariant');
+
+var _lastPath;
+var _currentPath = '/';
+
+/**
+ * Fake location handler that can be used outside the scope of the browser.  It
+ * tracks the current and previous path, as given to #push() and #replace().
+ */
+var MemoryLocation = {
+
+  type: 'memory',
+  onChange: null,
+
+  init: function(onChange) {
+    this.onChange = onChange;
+  },
+
+  destroy: function() {
+    this.onChange = null;
+    _lastPath = null;
+    _currentPath = '/';
+  },
+
+  getCurrentPath: function() {
+    return _currentPath;
+  },
+
+  push: function(path) {
+    _lastPath = _currentPath;
+    _currentPath = path;
+    this.onChange();
+  },
+
+  replace: function(path) {
+    _currentPath = path;
+    this.onChange();
+  },
+
+  back: function() {
+    invariant(
+      _lastPath,
+      'You cannot make the URL store go back more than once when it does not use the DOM'
+    );
+
+    _currentPath = _lastPath;
+    _lastPath = null;
+    this.onChange();
+  }
+};
+
+module.exports = MemoryLocation;
+
diff --git a/modules/helpers/getWindowPath.js b/modules/helpers/getWindowPath.js
new file mode 100644
index 00000000..108c2285
--- /dev/null
+++ b/modules/helpers/getWindowPath.js
@@ -0,0 +1,9 @@
+/**
+ * Returns the current URL path from `window.location`, including query string
+ */
+function getWindowPath() {
+  return window.location.pathname + window.location.search;
+}
+
+module.exports = getWindowPath;
+
diff --git a/modules/stores/URLStore.js b/modules/stores/URLStore.js
index 6ec2f489..1b94bce3 100644
--- a/modules/stores/URLStore.js
+++ b/modules/stores/URLStore.js
@@ -1,25 +1,14 @@
 var ExecutionEnvironment = require('react/lib/ExecutionEnvironment');
 var invariant = require('react/lib/invariant');
 var warning = require('react/lib/warning');
-
-var _location;
-var _currentPath = '/';
-var _lastPath = null;
-
-function getWindowChangeEvent(location) {
-  if (location === 'history')
-    return 'popstate';
-
-  return window.addEventListener ? 'hashchange' : 'onhashchange';
-}
-
-function getWindowPath() {
-  return window.location.pathname + window.location.search;
-}
+var Location = require('../helpers/Location');
 
 var EventEmitter = require('event-emitter');
 var _events = EventEmitter();
 
+var _location;
+var _locationHandler;
+
 function notifyChange() {
   _events.emit('change');
 }
@@ -56,13 +45,7 @@ var URLStore = {
    * Returns the value of the current URL path.
    */
   getCurrentPath: function () {
-    if (_location === 'history' || _location === 'disabledHistory')
-      return getWindowPath();
-
-    if (_location === 'hash')
-      return window.location.hash.substr(1);
-
-    return _currentPath;
+    return _locationHandler.getCurrentPath();
   },
 
   /**
@@ -72,19 +55,7 @@ var URLStore = {
     if (path === this.getCurrentPath())
       return;
 
-    if (_location === 'disabledHistory')
-      return window.location = path;
-
-    if (_location === 'history') {
-      window.history.pushState({ path: path }, '', path);
-      notifyChange();
-    } else if (_location === 'hash') {
-      window.location.hash = path;
-    } else {
-      _lastPath = _currentPath;
-      _currentPath = path;
-      notifyChange();
-    }
+    _locationHandler.push(path);
   },
 
   /**
@@ -92,35 +63,14 @@ var URLStore = {
    * to the browser's history.
    */
   replace: function (path) {
-    if (_location === 'disabledHistory') {
-      window.location.replace(path);
-    } else if (_location === 'history') {
-      window.history.replaceState({ path: path }, '', path);
-      notifyChange();
-    } else if (_location === 'hash') {
-      window.location.replace(getWindowPath() + '#' + path);
-    } else {
-      _currentPath = path;
-      notifyChange();
-    }
+    _locationHandler.replace(path);
   },
 
   /**
    * Reverts the URL to whatever it was before the last update.
    */
   back: function () {
-    if (_location != null) {
-      window.history.back();
-    } else {
-      invariant(
-        _lastPath,
-        'You cannot make the URL store go back more than once when it does not use the DOM'
-      );
-
-      _currentPath = _lastPath;
-      _lastPath = null;
-      notifyChange();
-    }
+    _locationHandler.back();
   },
 
   /**
@@ -151,30 +101,19 @@ var URLStore = {
     }
 
     if (location === 'history' && !supportsHistory()) {
-      _location = 'disabledHistory';
-      return;
+      location = 'disabled';
     }
 
-    var changeEvent = getWindowChangeEvent(location);
+    _location = location;
+    _locationHandler = Location[location];
 
     invariant(
-      changeEvent || location === 'disabledHistory',
+      _locationHandler,
       'The URL store location "' + location + '" is not valid. ' +
-      'It must be either "hash" or "history"'
+      'It must be any of: ' + Object.keys(Location)
     );
 
-    _location = location;
-
-    if (location === 'hash' && window.location.hash === '')
-      URLStore.replace('/');
-
-    if (window.addEventListener) {
-      window.addEventListener(changeEvent, notifyChange, false);
-    } else {
-      window.attachEvent(changeEvent, notifyChange);
-    }
-
-    notifyChange();
+    _locationHandler.init(notifyChange);
   },
 
   /**
@@ -184,16 +123,9 @@ var URLStore = {
     if (_location == null)
       return;
 
-    var changeEvent = getWindowChangeEvent(_location);
-
-    if (window.removeEventListener) {
-      window.removeEventListener(changeEvent, notifyChange, false);
-    } else {
-      window.detachEvent(changeEvent, notifyChange);
-    }
-
+    _locationHandler.destroy();
     _location = null;
-    _currentPath = '/';
+    _locationHandler = null;
   }
 
 };
diff --git a/specs/URLStore.spec.js b/specs/URLStore.spec.js
index 0225c72e..7d0e71eb 100644
--- a/specs/URLStore.spec.js
+++ b/specs/URLStore.spec.js
@@ -1,81 +1,101 @@
 require('./helper');
 var URLStore = require('../modules/stores/URLStore');
 
-describe('when a new path is pushed to the URL', function () {
-  beforeEach(function () {
-    URLStore.push('/a/b/c');
+describe('URLStore', function() {
+
+  beforeEach(function() {
+    URLStore.setup("hash");
   });
 
-  afterEach(function () {
+  afterEach(function() {
     URLStore.teardown();
   });
 
-  it('has the correct path', function () {
-    expect(URLStore.getCurrentPath()).toEqual('/a/b/c');
-  });
-});
+  describe('when a new path is pushed to the URL', function() {
+    beforeEach(function() {
+      URLStore.push('/a/b/c');
+    });
 
-describe('when a new path is used to replace the URL', function () {
-  beforeEach(function () {
-    URLStore.replace('/a/b/c');
+    it('has the correct path', function() {
+      expect(URLStore.getCurrentPath()).toEqual('/a/b/c');
+    });
   });
 
-  afterEach(function () {
-    URLStore.teardown();
-  });
+  describe('when a new path is used to replace the URL', function() {
+    beforeEach(function() {
+      URLStore.replace('/a/b/c');
+    });
 
-  it('has the correct path', function () {
-    expect(URLStore.getCurrentPath()).toEqual('/a/b/c');
+    it('has the correct path', function() {
+      expect(URLStore.getCurrentPath()).toEqual('/a/b/c');
+    });
   });
-});
 
-describe('when going back in history', function () {
-  afterEach(function () {
-    URLStore.teardown();
+  describe('when going back in history', function() {
+    it('has the correct path', function() {
+      URLStore.push('/a/b/c');
+      expect(URLStore.getCurrentPath()).toEqual('/a/b/c');
+
+      URLStore.push('/d/e/f');
+      expect(URLStore.getCurrentPath()).toEqual('/d/e/f');
+
+      URLStore.back();
+      expect(URLStore.getCurrentPath()).toEqual('/a/b/c');
+    });
   });
 
-  it('has the correct path', function () {
-    URLStore.push('/a/b/c');
-    expect(URLStore.getCurrentPath()).toEqual('/a/b/c');
+  describe('when navigating back to the root', function() {
+    beforeEach(function() {
+      URLStore.teardown();
 
-    URLStore.push('/d/e/f');
-    expect(URLStore.getCurrentPath()).toEqual('/d/e/f');
+      // simulating that the browser opens a page with #/dashboard
+      window.location.hash = '/dashboard';
+      URLStore.setup('hash');
+    });
 
-    URLStore.back();
-    expect(URLStore.getCurrentPath()).toEqual('/a/b/c');
+    it('should have the correct path', function() {
+      URLStore.push('/');
+      expect(window.location.hash).toEqual('#/');
+    });
   });
 
-  it('should not go back before recorded history', function () {
-    var error = false;
-    try {
-      URLStore.back();
-    } catch (e) {
-      error = true;
-    }
+  describe('when using history location handler', function() {
+    itShouldManagePathsForLocation('history');
+  });
 
-    expect(error).toEqual(true);
+  describe('when using memory location handler', function() {
+    itShouldManagePathsForLocation('memory');
   });
-});
 
-describe('when navigating back to the root', function() {
-  beforeEach(function () {
-    // not all tests are constructing and tearing down the URLStore.
-    // Let's set it up correctly once and then tear it down to ensure that all
-    // variables in the URLStore module are reset.
-    URLStore.setup('hash');
-    URLStore.teardown();
+  function itShouldManagePathsForLocation(location) {
+    var origPath;
 
-    // simulating that the browser opens a page with #/dashboard
-    window.location.hash = '/dashboard';
-    URLStore.setup('hash');
-  });
+    beforeEach(function() {
+      URLStore.teardown();
+      URLStore.setup(location);
+      origPath = URLStore.getCurrentPath();
+    });
 
-  afterEach(function () {
-    URLStore.teardown();
-  });
+    afterEach(function() {
+      URLStore.push(origPath);
+      expect(URLStore.getCurrentPath()).toEqual(origPath);
+    });
+
+    it('should manage the path correctly', function() {
+      URLStore.push('/test');
+      expect(URLStore.getCurrentPath()).toEqual('/test');
+
+      URLStore.push('/test/123');
+      expect(URLStore.getCurrentPath()).toEqual('/test/123');
+
+      URLStore.replace('/test/replaced');
+      expect(URLStore.getCurrentPath()).toEqual('/test/replaced');
+
+      URLStore.back();
+      expect(URLStore.getCurrentPath()).toEqual('/test');
+
+    });
+  }
 
-  it('should have the correct path', function () {
-    URLStore.push('/');
-    expect(window.location.hash).toEqual('#/');
-  });
 });
+