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

Commit 6fd36de

Browse files
mgolIgorMinar
authored andcommitted
feat($location): add support for History API state handling
Adds $location state method allowing to get/set a History API state via pushState & replaceState methods. Note that: - Angular treats states undefined and null as the same; trying to change one to the other without touching the URL won't do anything. This is necessary to prevent infinite digest loops when setting the URL to itself in IE<10 in the HTML5 hash fallback mode. - The state() method is not compatible with browsers not supporting the HTML5 History API, e.g. IE 9 or Android < 4.0. Closes #9027
1 parent 8ee1ba4 commit 6fd36de

File tree

8 files changed

+466
-62
lines changed

8 files changed

+466
-62
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@ngdoc error
2+
@name $location:nostate
3+
@fullName History API state support is available only in HTML5 mode and only in browsers supporting HTML5 History API
4+
@description
5+
6+
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).
7+
8+
To avoid this error, either drop support for those older browsers or avoid using this method.

src/ng/browser.js

+38-10
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ function Browser(window, document, $log, $sniffer) {
124124
//////////////////////////////////////////////////////////////
125125

126126
var lastBrowserUrl = location.href,
127+
lastHistoryState = history.state,
127128
baseElement = document.find('base'),
128129
reloadLocation = null;
129130

@@ -144,27 +145,38 @@ function Browser(window, document, $log, $sniffer) {
144145
* {@link ng.$location $location service} to change url.
145146
*
146147
* @param {string} url New url (when used as setter)
147-
* @param {boolean=} replace Should new url replace current history record ?
148+
* @param {boolean=} replace Should new url replace current history record?
149+
* @param {object=} state object to use with pushState/replaceState
148150
*/
149-
self.url = function(url, replace) {
151+
self.url = function(url, replace, state) {
152+
// In modern browsers `history.state` is `null` by default; treating it separately
153+
// from `undefined` would cause `$browser.url('/foo')` to change `history.state`
154+
// to undefined via `pushState`. Instead, let's change `undefined` to `null` here.
155+
if (isUndefined(state)) {
156+
state = null;
157+
}
158+
150159
// Android Browser BFCache causes location, history reference to become stale.
151160
if (location !== window.location) location = window.location;
152161
if (history !== window.history) history = window.history;
153162

154163
// setter
155164
if (url) {
156-
if (lastBrowserUrl == url) return;
165+
// Don't change anything if previous and current URLs and states match. This also prevents
166+
// IE<10 from getting into redirect loop when in LocationHashbangInHtml5Url mode.
167+
// See https://github.com/angular/angular.js/commit/ffb2701
168+
if (lastBrowserUrl === url && (!$sniffer.history || history.state === state)) {
169+
return;
170+
}
157171
var sameBase = lastBrowserUrl && stripHash(lastBrowserUrl) === stripHash(url);
158172
lastBrowserUrl = url;
159173
// Don't use history API if only the hash changed
160174
// due to a bug in IE10/IE11 which leads
161175
// to not firing a `hashchange` nor `popstate` event
162176
// in some cases (see #9143).
163-
if (!sameBase && $sniffer.history) {
164-
if (replace) history.replaceState(null, '', url);
165-
else {
166-
history.pushState(null, '', url);
167-
}
177+
if ($sniffer.history && (!sameBase || history.state !== state)) {
178+
history[replace ? 'replaceState' : 'pushState'](state, '', url);
179+
lastHistoryState = history.state;
168180
} else {
169181
if (!sameBase) {
170182
reloadLocation = url;
@@ -185,15 +197,31 @@ function Browser(window, document, $log, $sniffer) {
185197
}
186198
};
187199

200+
/**
201+
* @name $browser#state
202+
*
203+
* @description
204+
* This method is a getter.
205+
*
206+
* Return history.state or null if history.state is undefined.
207+
*
208+
* @returns {object} state
209+
*/
210+
self.state = function() {
211+
return isUndefined(history.state) ? null : history.state;
212+
};
213+
188214
var urlChangeListeners = [],
189215
urlChangeInit = false;
190216

191217
function fireUrlChange() {
192-
if (lastBrowserUrl == self.url()) return;
218+
if (lastBrowserUrl === self.url() && lastHistoryState === history.state) {
219+
return;
220+
}
193221

194222
lastBrowserUrl = self.url();
195223
forEach(urlChangeListeners, function(listener) {
196-
listener(self.url());
224+
listener(self.url(), history.state);
197225
});
198226
}
199227

src/ng/location.js

+110-30
Original file line numberDiff line numberDiff line change
@@ -303,9 +303,7 @@ function LocationHashbangInHtml5Url(appBase, hashPrefix) {
303303
}
304304

305305

306-
LocationHashbangInHtml5Url.prototype =
307-
LocationHashbangUrl.prototype =
308-
LocationHtml5Url.prototype = {
306+
var locationPrototype = {
309307

310308
/**
311309
* Are we in html5 mode?
@@ -314,7 +312,7 @@ LocationHashbangInHtml5Url.prototype =
314312
$$html5: false,
315313

316314
/**
317-
* Has any change been replacing ?
315+
* Has any change been replacing?
318316
* @private
319317
*/
320318
$$replace: false,
@@ -530,6 +528,46 @@ LocationHashbangInHtml5Url.prototype =
530528
}
531529
};
532530

531+
forEach([LocationHashbangInHtml5Url, LocationHashbangUrl, LocationHtml5Url], function (Location) {
532+
Location.prototype = Object.create(locationPrototype);
533+
534+
/**
535+
* @ngdoc method
536+
* @name $location#state
537+
*
538+
* @description
539+
* This method is getter / setter.
540+
*
541+
* Return the history state object when called without any parameter.
542+
*
543+
* Change the history state object when called with one parameter and return `$location`.
544+
* The state object is later passed to `pushState` or `replaceState`.
545+
*
546+
* NOTE: This method is supported only in HTML5 mode and only in browsers supporting
547+
* the HTML5 History API (i.e. methods `pushState` and `replaceState`). If you need to support
548+
* older browsers (like IE9 or Android < 4.0), don't use this method.
549+
*
550+
* @param {object=} state State object for pushState or replaceState
551+
* @return {object} state
552+
*/
553+
Location.prototype.state = function(state) {
554+
if (!arguments.length)
555+
return this.$$state;
556+
557+
if (Location !== LocationHtml5Url || !this.$$html5) {
558+
throw $locationMinErr('nostate', 'History API state support is available only ' +
559+
'in HTML5 mode and only in browsers supporting HTML5 History API');
560+
}
561+
// The user might modify `stateObject` after invoking `$location.state(stateObject)`
562+
// but we're changing the $$state reference to $browser.state() during the $digest
563+
// so the modification window is narrow.
564+
this.$$state = isUndefined(state) ? null : state;
565+
566+
return this;
567+
};
568+
});
569+
570+
533571
function locationGetter(property) {
534572
return function() {
535573
return this[property];
@@ -649,9 +687,14 @@ function $LocationProvider(){
649687
* details about event object. Upon successful change
650688
* {@link ng.$location#events_$locationChangeSuccess $locationChangeSuccess} is fired.
651689
*
690+
* The `newState` and `oldState` parameters may be defined only in HTML5 mode and when
691+
* the browser supports the HTML5 History API.
692+
*
652693
* @param {Object} angularEvent Synthetic event object.
653694
* @param {string} newUrl New URL
654695
* @param {string=} oldUrl URL that was before it was changed.
696+
* @param {string=} newState New history state object
697+
* @param {string=} oldState History state object that was before it was changed.
655698
*/
656699

657700
/**
@@ -661,9 +704,14 @@ function $LocationProvider(){
661704
* @description
662705
* Broadcasted after a URL was changed.
663706
*
707+
* The `newState` and `oldState` parameters may be defined only in HTML5 mode and when
708+
* the browser supports the HTML5 History API.
709+
*
664710
* @param {Object} angularEvent Synthetic event object.
665711
* @param {string} newUrl New URL
666712
* @param {string=} oldUrl URL that was before it was changed.
713+
* @param {string=} newState New history state object
714+
* @param {string=} oldState History state object that was before it was changed.
667715
*/
668716

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

739+
$location.$$state = $browser.state();
740+
691741
var IGNORE_URI_REGEXP = /^\s*(javascript|mailto):/i;
692742

743+
function setBrowserUrlWithFallback(url, replace, state) {
744+
var oldUrl = $location.url();
745+
var oldState = $location.$$state;
746+
try {
747+
$browser.url(url, replace, state);
748+
749+
// Make sure $location.state() returns referentially identical (not just deeply equal)
750+
// state object; this makes possible quick checking if the state changed in the digest
751+
// loop. Checking deep equality would be too expensive.
752+
$location.$$state = $browser.state();
753+
} catch (e) {
754+
// Restore old values if pushState fails
755+
$location.url(oldUrl);
756+
$location.$$state = oldState;
757+
758+
throw e;
759+
}
760+
}
761+
693762
$rootElement.on('click', function(event) {
694763
// TODO(vojta): rewrite link when opening in new tab/window (in legacy browser)
695764
// currently we open nice url link and redirect then
@@ -740,52 +809,63 @@ function $LocationProvider(){
740809
$browser.url($location.absUrl(), true);
741810
}
742811

743-
// update $location when $browser url changes
744-
$browser.onUrlChange(function(newUrl) {
745-
if ($location.absUrl() != newUrl) {
746-
$rootScope.$evalAsync(function() {
747-
var oldUrl = $location.absUrl();
812+
var initializing = true;
748813

749-
$location.$$parse(newUrl);
750-
if ($rootScope.$broadcast('$locationChangeStart', newUrl,
751-
oldUrl).defaultPrevented) {
752-
$location.$$parse(oldUrl);
753-
$browser.url(oldUrl);
754-
} else {
755-
afterLocationChange(oldUrl);
756-
}
757-
});
758-
if (!$rootScope.$$phase) $rootScope.$digest();
759-
}
814+
// update $location when $browser url changes
815+
$browser.onUrlChange(function(newUrl, newState) {
816+
$rootScope.$evalAsync(function() {
817+
var oldUrl = $location.absUrl();
818+
var oldState = $location.$$state;
819+
820+
$location.$$parse(newUrl);
821+
$location.$$state = newState;
822+
if ($rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl,
823+
newState, oldState).defaultPrevented) {
824+
$location.$$parse(oldUrl);
825+
$location.$$state = oldState;
826+
setBrowserUrlWithFallback(oldUrl, false, oldState);
827+
} else {
828+
initializing = false;
829+
afterLocationChange(oldUrl, oldState);
830+
}
831+
});
832+
if (!$rootScope.$$phase) $rootScope.$digest();
760833
});
761834

762835
// update browser
763-
var changeCounter = 0;
764836
$rootScope.$watch(function $locationWatch() {
765837
var oldUrl = $browser.url();
838+
var oldState = $browser.state();
766839
var currentReplace = $location.$$replace;
767840

768-
if (!changeCounter || oldUrl != $location.absUrl()) {
769-
changeCounter++;
841+
if (initializing || oldUrl !== $location.absUrl() ||
842+
($location.$$html5 && $sniffer.history && oldState !== $location.$$state)) {
843+
initializing = false;
844+
770845
$rootScope.$evalAsync(function() {
771-
if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl).
772-
defaultPrevented) {
846+
if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl,
847+
$location.$$state, oldState).defaultPrevented) {
773848
$location.$$parse(oldUrl);
849+
$location.$$state = oldState;
774850
} else {
775-
$browser.url($location.absUrl(), currentReplace);
776-
afterLocationChange(oldUrl);
851+
setBrowserUrlWithFallback($location.absUrl(), currentReplace,
852+
oldState === $location.$$state ? null : $location.$$state);
853+
afterLocationChange(oldUrl, oldState);
777854
}
778855
});
779856
}
857+
780858
$location.$$replace = false;
781859

782-
return changeCounter;
860+
// we don't need to return anything because $evalAsync will make the digest loop dirty when
861+
// there is a change
783862
});
784863

785864
return $location;
786865

787-
function afterLocationChange(oldUrl) {
788-
$rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl);
866+
function afterLocationChange(oldUrl, oldState) {
867+
$rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl,
868+
$location.$$state, oldState);
789869
}
790870
}];
791871
}

src/ngMock/angular-mocks.js

+13-3
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ angular.mock.$Browser = function() {
4646
self.onUrlChange = function(listener) {
4747
self.pollFns.push(
4848
function() {
49-
if (self.$$lastUrl != self.$$url) {
49+
if (self.$$lastUrl !== self.$$url || self.$$state !== self.$$lastState) {
5050
self.$$lastUrl = self.$$url;
51-
listener(self.$$url);
51+
self.$$lastState = self.$$state;
52+
listener(self.$$url, self.$$state);
5253
}
5354
}
5455
);
@@ -144,15 +145,24 @@ angular.mock.$Browser.prototype = {
144145
return pollFn;
145146
},
146147

147-
url: function(url, replace) {
148+
url: function(url, replace, state) {
149+
if (angular.isUndefined(state)) {
150+
state = null;
151+
}
148152
if (url) {
149153
this.$$url = url;
154+
// Native pushState serializes & copies the object; simulate it.
155+
this.$$state = angular.copy(state);
150156
return this;
151157
}
152158

153159
return this.$$url;
154160
},
155161

162+
state: function() {
163+
return this.$$state;
164+
},
165+
156166
cookies: function(name, value) {
157167
if (name) {
158168
if (angular.isUndefined(value)) {

0 commit comments

Comments
 (0)