Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit ba5f4fc

Browse files
committedSep 24, 2014
feat($location): add support for state and title in pushState
Adds $location pushState & replaceState methods acting as proxies to history pushState & replaceState methods. This allows using pushState to change state and title as well as URL. Note that these methods are not compatible with browsers not supporting the HTML5 History API, e.g. IE 9. Closes #9027
1 parent e81ae14 commit ba5f4fc

File tree

7 files changed

+233
-36
lines changed

7 files changed

+233
-36
lines changed
 

‎src/ng/browser.js

+18-9
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ function Browser(window, document, $log, $sniffer) {
123123
//////////////////////////////////////////////////////////////
124124

125125
var lastBrowserUrl = location.href,
126+
lastBrowserState = history.state,
126127
baseElement = document.find('base'),
127128
newLocation = null;
128129

@@ -143,23 +144,31 @@ function Browser(window, document, $log, $sniffer) {
143144
* {@link ng.$location $location service} to change url.
144145
*
145146
* @param {string} url New url (when used as setter)
146-
* @param {boolean=} replace Should new url replace current history record ?
147+
* @param {boolean=} replace Should new url replace current history record?
148+
* @param {object=} state object to use with pushState/replaceState
149+
* @param {string=} title to use with pushState/replaceState
147150
*/
148-
self.url = function(url, replace) {
151+
self.url = function(url, replace, state, title) {
149152
// Android Browser BFCache causes location, history reference to become stale.
150153
if (location !== window.location) location = window.location;
151154
if (history !== window.history) history = window.history;
152155

153156
// setter
154157
if (url) {
155-
if (lastBrowserUrl == url) return;
158+
if (lastBrowserUrl == url && (!$sniffer.history || equals(lastBrowserState, state))) {
159+
return;
160+
}
156161
lastBrowserUrl = url;
162+
lastBrowserState = copy(state);
157163
if ($sniffer.history) {
158-
if (replace) history.replaceState(null, '', url);
159-
else {
160-
history.pushState(null, '', url);
161-
// Crazy Opera Bug: http://my.opera.com/community/forums/topic.dml?id=1185462
162-
baseElement.attr('href', baseElement.attr('href'));
164+
title = title || '';
165+
state = state || null;
166+
try {
167+
history[replace ? 'replaceState' : 'pushState'](state, title, url);
168+
} catch (e) {
169+
throw minErr('$browser')('pstterr', 'pushState or replaceState failed; ' +
170+
'most likely the passed state object is too complex',
171+
e.stack || e.message || e);
163172
}
164173
} else {
165174
newLocation = url;
@@ -188,7 +197,7 @@ function Browser(window, document, $log, $sniffer) {
188197

189198
lastBrowserUrl = self.url();
190199
forEach(urlChangeListeners, function(listener) {
191-
listener(self.url());
200+
listener(self.url(), history.state, rawDocument.title);
192201
});
193202
}
194203

‎src/ng/location.js

+108-12
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,100 @@ LocationHashbangInHtml5Url.prototype =
530528
}
531529
};
532530

531+
forEach([LocationHashbangInHtml5Url, LocationHashbangUrl, LocationHtml5Url], function (Location) {
532+
extend(Location.prototype = {}, locationPrototype);
533+
});
534+
535+
/**
536+
* @name $location#$$state
537+
*
538+
* Current History API state.
539+
* @private
540+
*/
541+
LocationHtml5Url.prototype.$$state = null;
542+
543+
/**
544+
* @name $location#$$title
545+
*
546+
* Current History API title.
547+
* @private
548+
*/
549+
LocationHtml5Url.prototype.$$title = '';
550+
551+
/**
552+
* @ngdoc method
553+
* @name $location#state.
554+
*
555+
* @description
556+
* Return current history state.
557+
*
558+
* NOTE: this method is available only in HTML5 mode and only in browsers supporting
559+
* HTML5 History API. If you need to support older browsers (like IE9), don't use this
560+
* method!
561+
*
562+
* @return {object} state
563+
*/
564+
LocationHtml5Url.prototype.state = function() {
565+
return this.$$state;
566+
};
567+
568+
/**
569+
* @ngdoc method
570+
* @name $location#pushState
571+
*
572+
* @description
573+
* Invokes history.pushState. Changes url, state and title.
574+
*
575+
* NOTE: this method is available only in HTML5 mode and only in browsers supporting
576+
* HTML5 History API. If you need to support older browsers (like IE9), don't use this
577+
* method!
578+
*
579+
* @param {object=} state object for pushState
580+
* @param {string=} title for pushState (ignored by most browsers)
581+
* @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`)
582+
* @return {object} $location
583+
*/
584+
LocationHtml5Url.prototype.pushState = function(state, title, url, replace) {
585+
this.$$state = copy(state);
586+
this.$$title = title;
587+
this.$$replace = replace;
588+
this.url(url, replace);
589+
return this;
590+
};
591+
592+
/**
593+
* @ngdoc method
594+
* @name $location#replaceState.
595+
*
596+
* @description
597+
* Invokes history.replaceState. Changes url, state and title.
598+
*
599+
* NOTE: this method is available only in HTML5 mode and only in browsers supporting
600+
* HTML5 History API. If you need to support older browsers (like IE9), don't use this
601+
* method!
602+
*
603+
* @param {object=} state object for replaceState
604+
* @param {string=} title for replaceState (ignored by most browsers)
605+
* @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`)
606+
* @return {object} $location
607+
*/
608+
LocationHtml5Url.prototype.replaceState = function(state, title, url) {
609+
this.pushState(state, title, url, true);
610+
return this;
611+
};
612+
613+
614+
LocationHashbangInHtml5Url.prototype.state =
615+
LocationHashbangInHtml5Url.prototype.pushState =
616+
LocationHashbangInHtml5Url.prototype.replaceState =
617+
LocationHashbangUrl.prototype.state =
618+
LocationHashbangUrl.prototype.pushState =
619+
LocationHashbangUrl.prototype.replaceState = function() {
620+
throw $locationMinErr('psthtml4', 'History API state-related methods are available only ' +
621+
'in HTML5 mode and only in browsers supporting HTML5 History API');
622+
};
623+
624+
533625
function locationGetter(property) {
534626
return function() {
535627
return this[property];
@@ -738,7 +830,7 @@ function $LocationProvider(){
738830
}
739831

740832
// update $location when $browser url changes
741-
$browser.onUrlChange(function(newUrl) {
833+
$browser.onUrlChange(function(newUrl, state, title) {
742834
if ($location.absUrl() != newUrl) {
743835
$rootScope.$evalAsync(function() {
744836
var oldUrl = $location.absUrl();
@@ -747,9 +839,9 @@ function $LocationProvider(){
747839
if ($rootScope.$broadcast('$locationChangeStart', newUrl,
748840
oldUrl).defaultPrevented) {
749841
$location.$$parse(oldUrl);
750-
$browser.url(oldUrl);
842+
$browser.url($location.absUrl(), false, state, title);
751843
} else {
752-
afterLocationChange(oldUrl);
844+
afterLocationChange(oldUrl, state, title);
753845
}
754846
});
755847
if (!$rootScope.$$phase) $rootScope.$digest();
@@ -761,28 +853,32 @@ function $LocationProvider(){
761853
$rootScope.$watch(function $locationWatch() {
762854
var oldUrl = $browser.url();
763855
var currentReplace = $location.$$replace;
856+
var currentState = $location.$$state;
857+
var currentTitle = $location.$$title;
764858

765859
if (!changeCounter || oldUrl != $location.absUrl()) {
766860
changeCounter++;
767861
$rootScope.$evalAsync(function() {
768-
if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl).
862+
if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl, currentState, currentTitle).
769863
defaultPrevented) {
770864
$location.$$parse(oldUrl);
771865
} else {
772-
$browser.url($location.absUrl(), currentReplace);
773-
afterLocationChange(oldUrl);
866+
$browser.url($location.absUrl(), currentReplace, currentState, currentTitle);
867+
afterLocationChange(oldUrl, currentState, currentTitle);
774868
}
775869
});
776870
}
777871
$location.$$replace = false;
872+
delete $location.$$state;
873+
delete $location.$$title;
778874

779875
return changeCounter;
780876
});
781877

782878
return $location;
783879

784-
function afterLocationChange(oldUrl) {
785-
$rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl);
880+
function afterLocationChange(oldUrl, state, title) {
881+
$rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl, state, title);
786882
}
787883
}];
788884
}

‎src/ngMock/angular-mocks.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,11 @@ angular.mock.$Browser.prototype = {
144144
return pollFn;
145145
},
146146

147-
url: function(url, replace) {
147+
url: function(url, replace, state, title) {
148148
if (url) {
149149
this.$$url = url;
150+
this.$$state = state;
151+
this.$$title = title;
150152
return this;
151153
}
152154

‎test/ng/browserSpecs.js

+64-12
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
'use strict';
22

3+
var sniffer = {};
4+
35
function MockWindow() {
46
var events = {};
57
var timeouts = this.timeouts = [];
8+
var mockWindow = this;
69

710
this.setTimeout = function(fn) {
811
return timeouts.push(fn) - 1;
@@ -37,12 +40,25 @@ function MockWindow() {
3740

3841
this.location = {
3942
href: 'http://server/',
40-
replace: noop
43+
replace: function(url) {
44+
this.href = url;
45+
},
46+
};
47+
48+
this.document = {
49+
title: ''
4150
};
4251

4352
this.history = {
44-
replaceState: noop,
45-
pushState: noop
53+
state: null,
54+
pushState: function(state, title, url) {
55+
mockWindow.location.href = url;
56+
mockWindow.history.state = copy(state);
57+
mockWindow.document.title = title;
58+
},
59+
replaceState: function() {
60+
this.pushState.apply(this, arguments);
61+
}
4662
};
4763
}
4864

@@ -71,7 +87,7 @@ function MockDocument() {
7187

7288
describe('browser', function() {
7389
/* global Browser: false */
74-
var browser, fakeWindow, fakeDocument, logs, scripts, removedScripts, sniffer;
90+
var browser, fakeWindow, fakeDocument, logs, scripts, removedScripts;
7591

7692
beforeEach(function() {
7793
scripts = [];
@@ -80,9 +96,6 @@ describe('browser', function() {
8096
fakeWindow = new MockWindow();
8197
fakeDocument = new MockDocument();
8298

83-
var fakeBody = [{appendChild: function(node){scripts.push(node);},
84-
removeChild: function(node){removedScripts.push(node);}}];
85-
8699
logs = {log:[], warn:[], info:[], error:[]};
87100

88101
var fakeLog = {log: function() { logs.log.push(slice.call(arguments)); },
@@ -475,6 +488,45 @@ describe('browser', function() {
475488
});
476489
});
477490

491+
describe('url (when state or title passed)', function() {
492+
var currentHref;
493+
494+
beforeEach(function() {
495+
sniffer = {history: true, hashchange: true};
496+
currentHref = fakeWindow.location.href;
497+
});
498+
499+
it('should change state', function() {
500+
browser.url(currentHref + '/something', false, {prop: 'val1'});
501+
expect(fakeWindow.history.state).toEqual({prop: 'val1'});
502+
});
503+
504+
it('should do pushState with the same URL and a different state', function() {
505+
browser.url(currentHref, false, {prop: 'val1'});
506+
expect(fakeWindow.history.state).toEqual({prop: 'val1'});
507+
508+
browser.url(currentHref, false, null);
509+
expect(fakeWindow.history.state).toBe(null);
510+
511+
browser.url(currentHref, false, {prop: 'val2'});
512+
browser.url(currentHref, false, {prop: 'val3'});
513+
expect(fakeWindow.history.state).toEqual({prop: 'val3'});
514+
});
515+
516+
it('should not do pushState with the same URL and null state', function() {
517+
fakeWindow.history.state = {prop: 'val1'};
518+
browser.url(currentHref, false, null);
519+
expect(fakeWindow.history.state).toEqual({prop: 'val1'});
520+
});
521+
522+
it('should not do pushState with the same URL and the same non-null state', function() {
523+
browser.url(currentHref, false, {prop: 'val2'});
524+
fakeWindow.history.state = {prop: 'val3'};
525+
browser.url(currentHref, false, {prop: 'val2'});
526+
expect(fakeWindow.history.state).toEqual({prop: 'val3'});
527+
});
528+
});
529+
478530
describe('urlChange', function() {
479531
var callback;
480532

@@ -496,7 +548,7 @@ describe('browser', function() {
496548
fakeWindow.location.href = 'http://server/new';
497549

498550
fakeWindow.fire('popstate');
499-
expect(callback).toHaveBeenCalledWith('http://server/new');
551+
expect(callback).toHaveBeenCalledWith('http://server/new', null, '');
500552

501553
fakeWindow.fire('hashchange');
502554
fakeWindow.setTimeout.flush();
@@ -510,7 +562,7 @@ describe('browser', function() {
510562
fakeWindow.location.href = 'http://server/new';
511563

512564
fakeWindow.fire('popstate');
513-
expect(callback).toHaveBeenCalledWith('http://server/new');
565+
expect(callback).toHaveBeenCalledWith('http://server/new', null, '');
514566

515567
fakeWindow.fire('hashchange');
516568
fakeWindow.setTimeout.flush();
@@ -524,7 +576,7 @@ describe('browser', function() {
524576
fakeWindow.location.href = 'http://server/new';
525577

526578
fakeWindow.fire('hashchange');
527-
expect(callback).toHaveBeenCalledWith('http://server/new');
579+
expect(callback).toHaveBeenCalledWith('http://server/new', null, '');
528580

529581
fakeWindow.fire('popstate');
530582
fakeWindow.setTimeout.flush();
@@ -538,7 +590,7 @@ describe('browser', function() {
538590

539591
fakeWindow.location.href = 'http://server.new';
540592
fakeWindow.setTimeout.flush();
541-
expect(callback).toHaveBeenCalledWith('http://server.new');
593+
expect(callback).toHaveBeenCalledWith('http://server.new', null, '');
542594

543595
callback.reset();
544596

@@ -559,7 +611,7 @@ describe('browser', function() {
559611

560612
fakeWindow.location.href = 'http://server.new';
561613
fakeWindow.setTimeout.flush();
562-
expect(callback).toHaveBeenCalledWith('http://server.new');
614+
expect(callback).toHaveBeenCalledWith('http://server.new', null, '');
563615

564616
fakeWindow.fire('popstate');
565617
fakeWindow.fire('hashchange');

‎test/ng/locationSpec.js

+38-1
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,15 @@ describe('$location', function() {
285285
});
286286

287287

288+
it('pushState() should set state, title & url', function () {
289+
url.pushState({a: 2}, 'foo', '/bar');
290+
291+
expect(url.path()).toBe('/bar');
292+
expect(url.state()).toEqual({a: 2});
293+
expect(url.$$title).toBe('foo');
294+
});
295+
296+
288297
it('replace should set $$replace flag and return itself', function() {
289298
expect(url.$$replace).toBe(false);
290299

@@ -674,7 +683,8 @@ describe('$location', function() {
674683
$rootScope.$apply();
675684

676685
expect($browserUrl).toHaveBeenCalledOnce();
677-
expect($browserUrl.mostRecentCall.args).toEqual(['http://new.com/a/b#!/n/url', true]);
686+
expect($browserUrl.mostRecentCall.args).toEqual(['http://new.com/a/b#!/n/url', true,
687+
undefined, undefined]);
678688
expect($location.$$replace).toBe(false);
679689
}));
680690

@@ -1768,6 +1778,25 @@ describe('$location', function() {
17681778
});
17691779

17701780

1781+
function throwOnHistoryState(location) {
1782+
forEach([
1783+
function () {
1784+
location.state();
1785+
},
1786+
function () {
1787+
location.pushState({a: 2}, 'title', '/url');
1788+
},
1789+
function () {
1790+
location.replaceState({a: 2}, 'title', '/url');
1791+
}
1792+
], function (fun) {
1793+
expect(fun).toThrowMinErr('$location', 'psthtml4',
1794+
'History API state-related methods are available only ' +
1795+
'in HTML5 mode and only in browsers supporting HTML5 History API'
1796+
);
1797+
});
1798+
}
1799+
17711800
describe('LocationHashbangUrl', function() {
17721801
var location;
17731802

@@ -1822,6 +1851,10 @@ describe('$location', function() {
18221851
expect(location.url()).toBe('/http://example.com/');
18231852
expect(location.absUrl()).toBe('http://server/pre/index.html#/http://example.com/');
18241853
});
1854+
1855+
it('should throw on pushState() or replaceState()', function () {
1856+
throwOnHistoryState(location);
1857+
});
18251858
});
18261859

18271860

@@ -1848,5 +1881,9 @@ describe('$location', function() {
18481881
// Note: relies on the previous state!
18491882
expect(parseLinkAndReturn(locationIndex, 'someIgnoredAbsoluteHref', '#test')).toEqual('http://server/pre/index.html#!/otherPath#test');
18501883
});
1884+
1885+
it('should throw on pushState() or replaceState()', function () {
1886+
throwOnHistoryState(location);
1887+
});
18511888
});
18521889
});

‎test/ng/rafSpec.js

+1
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ describe('$$rAF', function() {
8080
var injector = createInjector(['ng', function($provide) {
8181
$provide.value('$window', {
8282
location : window.location,
83+
history : window.history,
8384
webkitRequestAnimationFrame: jasmine.createSpy('$window.webkitRequestAnimationFrame'),
8485
webkitCancelRequestAnimationFrame: jasmine.createSpy('$window.webkitCancelRequestAnimationFrame')
8586
});

‎test/ngRoute/routeSpec.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -883,7 +883,7 @@ describe('$route', function() {
883883

884884
expect($location.path()).toEqual('/bar/id3');
885885
expect($browserUrl.mostRecentCall.args)
886-
.toEqual(['http://server/#/bar/id3?extra=eId', true]);
886+
.toEqual(['http://server/#/bar/id3?extra=eId', true, undefined, undefined]);
887887
});
888888
});
889889
});

0 commit comments

Comments
 (0)
This repository has been archived.