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
This commit is contained in:
Michał Gołębiowski
2013-07-18 18:59:31 +02:00
committed by Igor Minar
parent 8ee1ba4b94
commit 6fd36deed9
8 changed files with 466 additions and 62 deletions

View File

@@ -1,8 +1,15 @@
'use strict';
var historyEntriesLength;
var sniffer = {};
function MockWindow() {
var events = {};
var timeouts = this.timeouts = [];
var locationHref = 'http://server/';
var mockWindow = this;
historyEntriesLength = 1;
this.setTimeout = function(fn) {
return timeouts.push(fn) - 1;
@@ -36,13 +43,30 @@ function MockWindow() {
};
this.location = {
href: 'http://server/',
replace: noop
get href() {
return locationHref;
},
set href(value) {
locationHref = value;
mockWindow.history.state = null;
historyEntriesLength++;
},
replace: function(url) {
locationHref = url;
mockWindow.history.state = null;
},
};
this.history = {
replaceState: noop,
pushState: noop
state: null,
pushState: function() {
this.replaceState.apply(this, arguments);
historyEntriesLength++;
},
replaceState: function(state, title, url) {
locationHref = url;
mockWindow.history.state = copy(state);
}
};
}
@@ -71,7 +95,7 @@ function MockDocument() {
describe('browser', function() {
/* global Browser: false */
var browser, fakeWindow, fakeDocument, logs, scripts, removedScripts, sniffer;
var browser, fakeWindow, fakeDocument, logs, scripts, removedScripts;
beforeEach(function() {
scripts = [];
@@ -80,9 +104,6 @@ describe('browser', function() {
fakeWindow = new MockWindow();
fakeDocument = new MockDocument();
var fakeBody = [{appendChild: function(node){scripts.push(node);},
removeChild: function(node){removedScripts.push(node);}}];
logs = {log:[], warn:[], info:[], error:[]};
var fakeLog = {log: function() { logs.log.push(slice.call(arguments)); },
@@ -93,6 +114,32 @@ describe('browser', function() {
browser = new Browser(fakeWindow, fakeDocument, fakeLog, sniffer);
});
describe('MockBrowser historyEntriesLength', function() {
it('should increment historyEntriesLength when setting location.href', function() {
expect(historyEntriesLength).toBe(1);
fakeWindow.location.href = '/foo';
expect(historyEntriesLength).toBe(2);
});
it('should not increment historyEntriesLength when using location.replace', function() {
expect(historyEntriesLength).toBe(1);
fakeWindow.location.replace('/foo');
expect(historyEntriesLength).toBe(1);
});
it('should increment historyEntriesLength when using history.pushState', function() {
expect(historyEntriesLength).toBe(1);
fakeWindow.history.pushState({a: 2}, 'foo', '/bar');
expect(historyEntriesLength).toBe(2);
});
it('should not increment historyEntriesLength when using history.replaceState', function() {
expect(historyEntriesLength).toBe(1);
fakeWindow.history.replaceState({a: 2}, 'foo', '/bar');
expect(historyEntriesLength).toBe(1);
});
});
it('should contain cookie cruncher', function() {
expect(browser.cookies).toBeDefined();
});
@@ -546,6 +593,68 @@ describe('browser', function() {
});
describe('url (when state passed)', function() {
var currentHref;
beforeEach(function() {
sniffer = {history: true, hashchange: true};
currentHref = fakeWindow.location.href;
});
it('should change state', function() {
browser.url(currentHref + '/something', false, {prop: 'val1'});
expect(fakeWindow.history.state).toEqual({prop: 'val1'});
});
it('should allow to set falsy states (except `undefined`)', function() {
fakeWindow.history.state = {prop: 'val1'};
browser.url(currentHref, false, null);
expect(fakeWindow.history.state).toBe(null);
browser.url(currentHref, false, false);
expect(fakeWindow.history.state).toBe(false);
browser.url(currentHref, false, '');
expect(fakeWindow.history.state).toBe('');
browser.url(currentHref, false, 0);
expect(fakeWindow.history.state).toBe(0);
});
it('should treat `undefined` state as `null`', function() {
fakeWindow.history.state = {prop: 'val1'};
browser.url(currentHref, false, undefined);
expect(fakeWindow.history.state).toBe(null);
});
it('should do pushState with the same URL and a different state', function() {
browser.url(currentHref, false, {prop: 'val1'});
expect(fakeWindow.history.state).toEqual({prop: 'val1'});
browser.url(currentHref, false, null);
expect(fakeWindow.history.state).toBe(null);
browser.url(currentHref, false, {prop: 'val2'});
browser.url(currentHref, false, {prop: 'val3'});
expect(fakeWindow.history.state).toEqual({prop: 'val3'});
});
it('should do pushState with the same URL and null state', function() {
fakeWindow.history.state = {prop: 'val1'};
browser.url(currentHref, false, null);
expect(fakeWindow.history.state).toEqual(null);
});
it('should do pushState with the same URL and the same non-null state', function() {
browser.url(currentHref, false, {prop: 'val2'});
fakeWindow.history.state = {prop: 'val3'};
browser.url(currentHref, false, {prop: 'val2'});
expect(fakeWindow.history.state).toEqual({prop: 'val2'});
});
});
describe('urlChange', function() {
var callback;
@@ -567,7 +676,7 @@ describe('browser', function() {
fakeWindow.location.href = 'http://server/new';
fakeWindow.fire('popstate');
expect(callback).toHaveBeenCalledWith('http://server/new');
expect(callback).toHaveBeenCalledWith('http://server/new', null);
fakeWindow.fire('hashchange');
fakeWindow.setTimeout.flush();
@@ -581,7 +690,7 @@ describe('browser', function() {
fakeWindow.location.href = 'http://server/new';
fakeWindow.fire('popstate');
expect(callback).toHaveBeenCalledWith('http://server/new');
expect(callback).toHaveBeenCalledWith('http://server/new', null);
fakeWindow.fire('hashchange');
fakeWindow.setTimeout.flush();
@@ -595,7 +704,7 @@ describe('browser', function() {
fakeWindow.location.href = 'http://server/new';
fakeWindow.fire('hashchange');
expect(callback).toHaveBeenCalledWith('http://server/new');
expect(callback).toHaveBeenCalledWith('http://server/new', null);
fakeWindow.fire('popstate');
fakeWindow.setTimeout.flush();
@@ -609,7 +718,7 @@ describe('browser', function() {
fakeWindow.location.href = 'http://server.new';
fakeWindow.setTimeout.flush();
expect(callback).toHaveBeenCalledWith('http://server.new');
expect(callback).toHaveBeenCalledWith('http://server.new', null);
callback.reset();
@@ -630,7 +739,7 @@ describe('browser', function() {
fakeWindow.location.href = 'http://server/#new';
fakeWindow.setTimeout.flush();
expect(callback).toHaveBeenCalledWith('http://server/#new');
expect(callback).toHaveBeenCalledWith('http://server/#new', null);
fakeWindow.fire('popstate');
fakeWindow.fire('hashchange');
@@ -801,7 +910,7 @@ describe('browser', function() {
fakeWindow.location.href = 'http://server/some/deep/path';
var changeUrlCount = 0;
var _url = browser.url;
browser.url = function(newUrl, replace) {
browser.url = function(newUrl, replace, state) {
if (newUrl) {
changeUrlCount++;
}
@@ -817,7 +926,7 @@ describe('browser', function() {
// from $location for rewriting the initial url into a hash url
expect(browser.url).toHaveBeenCalledWith('http://server/#/some/deep/path', true);
// from the initial call to the watch in $location for watching $location
expect(browser.url).toHaveBeenCalledWith('http://server/#/some/deep/path', false);
expect(browser.url).toHaveBeenCalledWith('http://server/#/some/deep/path', false, null);
expect(changeUrlCount).toBe(2);
});
@@ -836,11 +945,13 @@ describe('browser', function() {
var current = fakeWindow.location.href;
var newUrl = 'notyet';
sniffer.history = false;
expect(historyEntriesLength).toBe(1);
browser.url(newUrl, true);
expect(browser.url()).toBe(newUrl);
expect(historyEntriesLength).toBe(1);
$rootScope.$digest();
expect(browser.url()).toBe(newUrl);
expect(fakeWindow.location.href).toBe(current);
expect(historyEntriesLength).toBe(1);
});
});

View File

@@ -388,6 +388,36 @@ describe('$location', function() {
});
describe('state', function () {
it('should set $$state and return itself', function() {
expect(url.$$state).toEqual(null);
var returned = url.state({a: 2});
expect(url.$$state).toEqual({a: 2});
expect(returned).toBe(url);
});
it('should set state', function () {
url.state({a: 2});
expect(url.state()).toEqual({a: 2});
});
it('should allow to set both URL and state', function() {
url.url('/foo').state({a: 2});
expect(url.url()).toEqual('/foo');
expect(url.state()).toEqual({a: 2});
});
it('should allow to mix state and various URL functions', function() {
url.path('/foo').hash('abcd').state({a: 2}).search('bar', 'baz');
expect(url.path()).toEqual('/foo');
expect(url.state()).toEqual({a: 2});
expect(url.search() && url.search().bar).toBe('baz');
expect(url.hash()).toEqual('abcd');
});
});
describe('encoding', function() {
it('should encode special characters', function() {
@@ -684,7 +714,7 @@ describe('$location', function() {
$rootScope.$apply();
expect($browserUrl).toHaveBeenCalledOnce();
expect($browserUrl.mostRecentCall.args).toEqual(['http://new.com/a/b#!/n/url', true]);
expect($browserUrl.mostRecentCall.args).toEqual(['http://new.com/a/b#!/n/url', true, null]);
expect($location.$$replace).toBe(false);
}));
@@ -721,6 +751,122 @@ describe('$location', function() {
}));
});
describe('wiring in html5 mode', function() {
beforeEach(initService({html5Mode: true, supportHistory: true}));
beforeEach(inject(initBrowser({url:'http://new.com/a/b/', basePath: '/a/b/'})));
it('should initialize state to $browser.state()', inject(function($browser) {
$browser.$$state = {a: 2};
inject(function($location) {
expect($location.state()).toEqual({a: 2});
});
}));
it('should update $location when browser state changes', inject(function($browser, $location) {
$browser.url('http://new.com/a/b/', false, {b: 3});
$browser.poll();
expect($location.state()).toEqual({b: 3});
}));
it('should replace browser url & state when replace() was called at least once',
inject(function($rootScope, $location, $browser) {
var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough();
$location.path('/n/url').state({a: 2}).replace();
$rootScope.$apply();
expect($browserUrl).toHaveBeenCalledOnce();
expect($browserUrl.mostRecentCall.args).toEqual(['http://new.com/a/b/n/url', true, {a: 2}]);
expect($location.$$replace).toBe(false);
expect($location.$$state).toEqual({a: 2});
}));
it('should use only the most recent url & state definition',
inject(function($rootScope, $location, $browser) {
var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough();
$location.path('/n/url').state({a: 2}).replace().state({b: 3}).path('/o/url');
$rootScope.$apply();
expect($browserUrl).toHaveBeenCalledOnce();
expect($browserUrl.mostRecentCall.args).toEqual(['http://new.com/a/b/o/url', true, {b: 3}]);
expect($location.$$replace).toBe(false);
expect($location.$$state).toEqual({b: 3});
}));
it('should allow to set state without touching the URL',
inject(function($rootScope, $location, $browser) {
var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough();
$location.state({a: 2}).replace().state({b: 3});
$rootScope.$apply();
expect($browserUrl).toHaveBeenCalledOnce();
expect($browserUrl.mostRecentCall.args).toEqual(['http://new.com/a/b/', true, {b: 3}]);
expect($location.$$replace).toBe(false);
expect($location.$$state).toEqual({b: 3});
}));
it('should always reset replace flag after running watch', inject(function($rootScope, $location) {
// init watches
$location.url('/initUrl').state({a: 2});
$rootScope.$apply();
// changes url & state but resets them before digest
$location.url('/newUrl').state({a: 2}).replace().state({b: 3}).url('/initUrl');
$rootScope.$apply();
expect($location.$$replace).toBe(false);
// set the url to the old value
$location.url('/newUrl').state({a: 2}).replace();
$rootScope.$apply();
expect($location.$$replace).toBe(false);
// doesn't even change url only calls replace()
$location.replace();
$rootScope.$apply();
expect($location.$$replace).toBe(false);
}));
it('should allow to modify state only before digest',
inject(function($rootScope, $location, $browser) {
var o = {a: 2};
$location.state(o);
o.a = 3;
$rootScope.$apply();
expect($browser.state()).toEqual({a: 3});
o.a = 4;
$rootScope.$apply();
expect($browser.state()).toEqual({a: 3});
}));
it('should make $location.state() referencially identical with $browser.state() after digest',
inject(function($rootScope, $location, $browser) {
$location.state({a: 2});
$rootScope.$apply();
expect($location.state()).toBe($browser.state());
}));
it('should allow to query the state after digest',
inject(function($rootScope, $location) {
$location.url('/foo').state({a: 2});
$rootScope.$apply();
expect($location.state()).toEqual({a: 2});
}));
it('should reset the state on .url() after digest',
inject(function($rootScope, $location, $browser) {
$location.url('/foo').state({a: 2});
$rootScope.$apply();
var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough();
$location.url('/bar');
$rootScope.$apply();
expect($browserUrl).toHaveBeenCalledOnce();
expect($browserUrl.mostRecentCall.args).toEqual(['http://new.com/a/b/bar', false, null]);
}));
});
// html5 history is disabled
describe('disabled history', function() {
@@ -1771,9 +1917,21 @@ describe('$location', function() {
"$location in HTML5 mode requires a <base> tag to be present!");
});
});
it('should support state', function() {
expect(location.state({a: 2}).state()).toEqual({a: 2});
});
});
function throwOnState(location) {
expect(function () {
location.state({a: 2});
}).toThrowMinErr('$location', 'nostate', 'History API state support is available only ' +
'in HTML5 mode and only in browsers supporting HTML5 History API'
);
}
describe('LocationHashbangUrl', function() {
var location;
@@ -1828,6 +1986,10 @@ describe('$location', function() {
expect(location.url()).toBe('/http://example.com/');
expect(location.absUrl()).toBe('http://server/pre/index.html#/http://example.com/');
});
it('should throw on url(urlString, stateObject)', function () {
throwOnState(location);
});
});
@@ -1854,5 +2016,9 @@ describe('$location', function() {
// Note: relies on the previous state!
expect(parseLinkAndReturn(locationIndex, 'someIgnoredAbsoluteHref', '#test')).toEqual('http://server/pre/index.html#!/otherPath#test');
});
it('should throw on url(urlString, stateObject)', function () {
throwOnState(location);
});
});
});

View File

@@ -79,7 +79,8 @@ describe('$$rAF', function() {
//we need to create our own injector to work around the ngMock overrides
var injector = createInjector(['ng', function($provide) {
$provide.value('$window', {
location : window.location,
location: window.location,
history: window.history,
webkitRequestAnimationFrame: jasmine.createSpy('$window.webkitRequestAnimationFrame'),
webkitCancelRequestAnimationFrame: jasmine.createSpy('$window.webkitCancelRequestAnimationFrame')
});

View File

@@ -883,7 +883,7 @@ describe('$route', function() {
expect($location.path()).toEqual('/bar/id3');
expect($browserUrl.mostRecentCall.args)
.toEqual(['http://server/#/bar/id3?extra=eId', true]);
.toEqual(['http://server/#/bar/id3?extra=eId', true, null]);
});
});
});