Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

feat($location): add support for History API state handling #9027

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/content/error/$location/nostate.ngdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@ngdoc error
@name $location:nostate
@fullName History API state support is available only in HTML5 mode and only in browsers supporting HTML5 History API
@description

This error occurs when the {@link ng.$location#state $location.state} method is used when {@link ng.$locationProvider#html5Mode $locationProvider.html5Mode} is not turned on or the browser used doesn't support the HTML5 History API (for example, IE9 or Android 2.3).

To avoid this error, either drop support for those older browsers or avoid using this method.
48 changes: 38 additions & 10 deletions src/ng/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function Browser(window, document, $log, $sniffer) {
//////////////////////////////////////////////////////////////

var lastBrowserUrl = location.href,
lastHistoryState = history.state,
baseElement = document.find('base'),
reloadLocation = null;

Expand All @@ -144,27 +145,38 @@ function Browser(window, document, $log, $sniffer) {
* {@link ng.$location $location service} to change url.
*
* @param {string} url New url (when used as setter)
* @param {boolean=} replace Should new url replace current history record ?
* @param {boolean=} replace Should new url replace current history record?
* @param {object=} state object to use with pushState/replaceState
*/
self.url = function(url, replace) {
self.url = function(url, replace, state) {
// In modern browsers `history.state` is `null` by default; treating it separately
// from `undefined` would cause `$browser.url('/foo')` to change `history.state`
// to undefined via `pushState`. Instead, let's change `undefined` to `null` here.
if (isUndefined(state)) {
state = null;
}

// Android Browser BFCache causes location, history reference to become stale.
if (location !== window.location) location = window.location;
if (history !== window.history) history = window.history;

// setter
if (url) {
if (lastBrowserUrl == url) return;
// Don't change anything if previous and current URLs and states match. This also prevents
// IE<10 from getting into redirect loop when in LocationHashbangInHtml5Url mode.
// See https://github.com/angular/angular.js/commit/ffb2701
if (lastBrowserUrl === url && (!$sniffer.history || history.state === state)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this fix is just for ie<10 as you documented then we'll never need to check history.state, no?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated a comment

return;
}
var sameBase = lastBrowserUrl && stripHash(lastBrowserUrl) === stripHash(url);
lastBrowserUrl = url;
// Don't use history API if only the hash changed
// due to a bug in IE10/IE11 which leads
// to not firing a `hashchange` nor `popstate` event
// in some cases (see #9143).
if (!sameBase && $sniffer.history) {
if (replace) history.replaceState(null, '', url);
else {
history.pushState(null, '', url);
}
if ($sniffer.history && (!sameBase || history.state !== state)) {
history[replace ? 'replaceState' : 'pushState'](state, '', url);
lastHistoryState = history.state;
} else {
if (!sameBase) {
reloadLocation = url;
Expand All @@ -185,15 +197,31 @@ function Browser(window, document, $log, $sniffer) {
}
};

/**
* @name $browser#state
*
* @description
* This method is a getter.
*
* Return history.state or null if history.state is undefined.
*
* @returns {object} state
*/
self.state = function() {
return isUndefined(history.state) ? null : history.state;
};

var urlChangeListeners = [],
urlChangeInit = false;

function fireUrlChange() {
if (lastBrowserUrl == self.url()) return;
if (lastBrowserUrl === self.url() && lastHistoryState === history.state) {
return;
}

lastBrowserUrl = self.url();
forEach(urlChangeListeners, function(listener) {
listener(self.url());
listener(self.url(), history.state);
});
}

Expand Down
140 changes: 110 additions & 30 deletions src/ng/location.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,7 @@ function LocationHashbangInHtml5Url(appBase, hashPrefix) {
}


LocationHashbangInHtml5Url.prototype =
LocationHashbangUrl.prototype =
LocationHtml5Url.prototype = {
var locationPrototype = {

/**
* Are we in html5 mode?
Expand All @@ -314,7 +312,7 @@ LocationHashbangInHtml5Url.prototype =
$$html5: false,

/**
* Has any change been replacing ?
* Has any change been replacing?
* @private
*/
$$replace: false,
Expand Down Expand Up @@ -530,6 +528,46 @@ LocationHashbangInHtml5Url.prototype =
}
};

forEach([LocationHashbangInHtml5Url, LocationHashbangUrl, LocationHtml5Url], function (Location) {
Location.prototype = Object.create(locationPrototype);

/**
* @ngdoc method
* @name $location#state
*
* @description
* This method is getter / setter.
*
* Return the history state object when called without any parameter.
*
* Change the history state object when called with one parameter and return `$location`.
* The state object is later passed to `pushState` or `replaceState`.
*
* NOTE: This method is supported only in HTML5 mode and only in browsers supporting
* the HTML5 History API (i.e. methods `pushState` and `replaceState`). If you need to support
* older browsers (like IE9 or Android < 4.0), don't use this method.
*
* @param {object=} state State object for pushState or replaceState
* @return {object} state
*/
Location.prototype.state = function(state) {
if (!arguments.length)
return this.$$state;

if (Location !== LocationHtml5Url || !this.$$html5) {
throw $locationMinErr('nostate', 'History API state support is available only ' +
'in HTML5 mode and only in browsers supporting HTML5 History API');
}
// The user might modify `stateObject` after invoking `$location.state(stateObject)`
// but we're changing the $$state reference to $browser.state() during the $digest
// so the modification window is narrow.
this.$$state = isUndefined(state) ? null : state;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so you decided not to copy after all?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that's what you wanted? :) I even added a test confirming state can be modified before digest but not after that.

I don't care a lot either way, whatever you prefer.


return this;
};
});


function locationGetter(property) {
return function() {
return this[property];
Expand Down Expand Up @@ -649,9 +687,14 @@ function $LocationProvider(){
* details about event object. Upon successful change
* {@link ng.$location#events_$locationChangeSuccess $locationChangeSuccess} is fired.
*
* The `newState` and `oldState` parameters may be defined only in HTML5 mode and when
* the browser supports the HTML5 History API.
*
* @param {Object} angularEvent Synthetic event object.
* @param {string} newUrl New URL
* @param {string=} oldUrl URL that was before it was changed.
* @param {string=} newState New history state object
* @param {string=} oldState History state object that was before it was changed.
*/

/**
Expand All @@ -661,9 +704,14 @@ function $LocationProvider(){
* @description
* Broadcasted after a URL was changed.
*
* The `newState` and `oldState` parameters may be defined only in HTML5 mode and when
* the browser supports the HTML5 History API.
*
* @param {Object} angularEvent Synthetic event object.
* @param {string} newUrl New URL
* @param {string=} oldUrl URL that was before it was changed.
* @param {string=} newState New history state object
* @param {string=} oldState History state object that was before it was changed.
*/

this.$get = ['$rootScope', '$browser', '$sniffer', '$rootElement',
Expand All @@ -688,8 +736,29 @@ function $LocationProvider(){
$location = new LocationMode(appBase, '#' + hashPrefix);
$location.$$parseLinkUrl(initialUrl, initialUrl);

$location.$$state = $browser.state();

var IGNORE_URI_REGEXP = /^\s*(javascript|mailto):/i;

function setBrowserUrlWithFallback(url, replace, state) {
var oldUrl = $location.url();
var oldState = $location.$$state;
try {
$browser.url(url, replace, state);

// Make sure $location.state() returns referentially identical (not just deeply equal)
// state object; this makes possible quick checking if the state changed in the digest
// loop. Checking deep equality would be too expensive.
$location.$$state = $browser.state();
} catch (e) {
// Restore old values if pushState fails
$location.url(oldUrl);
$location.$$state = oldState;

throw e;
}
}

$rootElement.on('click', function(event) {
// TODO(vojta): rewrite link when opening in new tab/window (in legacy browser)
// currently we open nice url link and redirect then
Expand Down Expand Up @@ -740,52 +809,63 @@ function $LocationProvider(){
$browser.url($location.absUrl(), true);
}

// update $location when $browser url changes
$browser.onUrlChange(function(newUrl) {
if ($location.absUrl() != newUrl) {
$rootScope.$evalAsync(function() {
var oldUrl = $location.absUrl();
var initializing = true;

$location.$$parse(newUrl);
if ($rootScope.$broadcast('$locationChangeStart', newUrl,
oldUrl).defaultPrevented) {
$location.$$parse(oldUrl);
$browser.url(oldUrl);
} else {
afterLocationChange(oldUrl);
}
});
if (!$rootScope.$$phase) $rootScope.$digest();
}
// update $location when $browser url changes
$browser.onUrlChange(function(newUrl, newState) {
$rootScope.$evalAsync(function() {
var oldUrl = $location.absUrl();
var oldState = $location.$$state;

$location.$$parse(newUrl);
$location.$$state = newState;
if ($rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl,
newState, oldState).defaultPrevented) {
$location.$$parse(oldUrl);
$location.$$state = oldState;
setBrowserUrlWithFallback(oldUrl, false, oldState);
} else {
initializing = false;
afterLocationChange(oldUrl, oldState);
}
});
if (!$rootScope.$$phase) $rootScope.$digest();
});

// update browser
var changeCounter = 0;
$rootScope.$watch(function $locationWatch() {
var oldUrl = $browser.url();
var oldState = $browser.state();
var currentReplace = $location.$$replace;

if (!changeCounter || oldUrl != $location.absUrl()) {
changeCounter++;
if (initializing || oldUrl !== $location.absUrl() ||
($location.$$html5 && $sniffer.history && oldState !== $location.$$state)) {
initializing = false;

$rootScope.$evalAsync(function() {
if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl).
defaultPrevented) {
if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl,
$location.$$state, oldState).defaultPrevented) {
$location.$$parse(oldUrl);
$location.$$state = oldState;
} else {
$browser.url($location.absUrl(), currentReplace);
afterLocationChange(oldUrl);
setBrowserUrlWithFallback($location.absUrl(), currentReplace,
oldState === $location.$$state ? null : $location.$$state);
afterLocationChange(oldUrl, oldState);
}
});
}

$location.$$replace = false;

return changeCounter;
// we don't need to return anything because $evalAsync will make the digest loop dirty when
// there is a change
});

return $location;

function afterLocationChange(oldUrl) {
$rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl);
function afterLocationChange(oldUrl, oldState) {
$rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl,
$location.$$state, oldState);
}
}];
}
16 changes: 13 additions & 3 deletions src/ngMock/angular-mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ angular.mock.$Browser = function() {
self.onUrlChange = function(listener) {
self.pollFns.push(
function() {
if (self.$$lastUrl != self.$$url) {
if (self.$$lastUrl !== self.$$url || self.$$state !== self.$$lastState) {
self.$$lastUrl = self.$$url;
listener(self.$$url);
self.$$lastState = self.$$state;
listener(self.$$url, self.$$state);
}
}
);
Expand Down Expand Up @@ -144,15 +145,24 @@ angular.mock.$Browser.prototype = {
return pollFn;
},

url: function(url, replace) {
url: function(url, replace, state) {
if (angular.isUndefined(state)) {
state = null;
}
if (url) {
this.$$url = url;
// Native pushState serializes & copies the object; simulate it.
this.$$state = angular.copy(state);
return this;
}

return this.$$url;
},

state: function() {
return this.$$state;
},

cookies: function(name, value) {
if (name) {
if (angular.isUndefined(value)) {
Expand Down
Loading