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

@@ -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.

View File

@@ -124,6 +124,7 @@ function Browser(window, document, $log, $sniffer) {
//////////////////////////////////////////////////////////////
var lastBrowserUrl = location.href,
lastHistoryState = history.state,
baseElement = document.find('base'),
reloadLocation = null;
@@ -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)) {
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;
@@ -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);
});
}

View File

@@ -303,9 +303,7 @@ function LocationHashbangInHtml5Url(appBase, hashPrefix) {
}
LocationHashbangInHtml5Url.prototype =
LocationHashbangUrl.prototype =
LocationHtml5Url.prototype = {
var locationPrototype = {
/**
* Are we in html5 mode?
@@ -314,7 +312,7 @@ LocationHashbangInHtml5Url.prototype =
$$html5: false,
/**
* Has any change been replacing ?
* Has any change been replacing?
* @private
*/
$$replace: false,
@@ -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;
return this;
};
});
function locationGetter(property) {
return function() {
return this[property];
@@ -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.
*/
/**
@@ -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',
@@ -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
@@ -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);
}
}];
}

View File

@@ -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);
}
}
);
@@ -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)) {

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]);
});
});
});