From d7c0ebde7d8b7ce980b083d6d08df798316c19ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Go=C5=82e=CC=A8biowski?= Date: Thu, 18 Jul 2013 18:59:31 +0200 Subject: [PATCH] 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 --- docs/content/error/$location/nostate.ngdoc | 8 + src/ng/browser.js | 48 ++++-- src/ng/location.js | 140 +++++++++++++---- src/ngMock/angular-mocks.js | 16 +- test/ng/browserSpecs.js | 139 +++++++++++++++-- test/ng/locationSpec.js | 168 ++++++++++++++++++++- test/ng/rafSpec.js | 3 +- test/ngRoute/routeSpec.js | 2 +- 8 files changed, 464 insertions(+), 60 deletions(-) create mode 100644 docs/content/error/$location/nostate.ngdoc diff --git a/docs/content/error/$location/nostate.ngdoc b/docs/content/error/$location/nostate.ngdoc new file mode 100644 index 000000000000..de91316971ed --- /dev/null +++ b/docs/content/error/$location/nostate.ngdoc @@ -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. diff --git a/src/ng/browser.js b/src/ng/browser.js index 1f86da6cb3f3..b4cb2776ee0e 100644 --- a/src/ng/browser.js +++ b/src/ng/browser.js @@ -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); }); } diff --git a/src/ng/location.js b/src/ng/location.js index f7275319dcb9..8f5baf51599e 100644 --- a/src/ng/location.js +++ b/src/ng/location.js @@ -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); } }]; } diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 24bbcd41371c..7d942204e56b 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -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)) { diff --git a/test/ng/browserSpecs.js b/test/ng/browserSpecs.js index a50a9ed4c7e2..421f73c8659b 100755 --- a/test/ng/browserSpecs.js +++ b/test/ng/browserSpecs.js @@ -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'); @@ -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); }); }); diff --git a/test/ng/locationSpec.js b/test/ng/locationSpec.js index 371249633e6a..12996915489b 100644 --- a/test/ng/locationSpec.js +++ b/test/ng/locationSpec.js @@ -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 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); + }); }); }); diff --git a/test/ng/rafSpec.js b/test/ng/rafSpec.js index 46313b08c4c7..007531f1c810 100644 --- a/test/ng/rafSpec.js +++ b/test/ng/rafSpec.js @@ -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') }); diff --git a/test/ngRoute/routeSpec.js b/test/ngRoute/routeSpec.js index 8a0a370f0615..612832a5a2d4 100644 --- a/test/ngRoute/routeSpec.js +++ b/test/ngRoute/routeSpec.js @@ -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]); }); }); });