diff --git a/src/ng/browser.js b/src/ng/browser.js index 8bd6b424bff8..fbebe01b3b8b 100644 --- a/src/ng/browser.js +++ b/src/ng/browser.js @@ -135,6 +135,26 @@ function Browser(window, document, $log, $sniffer) { cacheState(); lastHistoryState = cachedState; + /** + * @name $browser#forceReloadLocationUpdate + * + * @description + * This method is a setter. + * + * If the reloadLocation variable is already set, it will be reset to + * the passed-in URL. + * + * NOTE: this api is intended for use only by the $location service in the + * $locationWatch function. + * + * @param {string} url New url + */ + self.forceReloadLocationUpdate = function(url) { + if (reloadLocation) { + reloadLocation = url; + } + }; + /** * @name $browser#url * diff --git a/src/ng/location.js b/src/ng/location.js index 54dbf2ea57ac..d8a76cec3762 100644 --- a/src/ng/location.js +++ b/src/ng/location.js @@ -891,7 +891,7 @@ function $LocationProvider() { $browser.url($location.absUrl(), true); } - var initializing = true; + var initializing = true, previousOldUrl = null, previousNewUrl = null; // update $location when $browser url changes $browser.onUrlChange(function(newUrl, newState) { @@ -934,26 +934,35 @@ function $LocationProvider() { if (initializing || urlOrStateChanged) { initializing = false; - $rootScope.$evalAsync(function() { - var newUrl = $location.absUrl(); - var defaultPrevented = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl, - $location.$$state, oldState).defaultPrevented; - - // if the location was changed by a `$locationChangeStart` handler then stop - // processing this location change - if ($location.absUrl() !== newUrl) return; - - if (defaultPrevented) { - $location.$$parse(oldUrl); - $location.$$state = oldState; - } else { - if (urlOrStateChanged) { - setBrowserUrlWithFallback(newUrl, currentReplace, - oldState === $location.$$state ? null : $location.$$state); + if ((previousOldUrl !== oldUrl) || (previousNewUrl !== newUrl)) { + previousOldUrl = oldUrl, previousNewUrl = newUrl; + + $rootScope.$evalAsync(function() { + var newUrl = $location.absUrl(); + var defaultPrevented = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl, + $location.$$state, oldState).defaultPrevented; + + // if the location was changed by a `$locationChangeStart` handler then stop + // processing this location change + if ($location.absUrl() !== newUrl) return; + + if (defaultPrevented) { + $location.$$parse(oldUrl); + $location.$$state = oldState; + } else { + if (urlOrStateChanged) { + setBrowserUrlWithFallback(newUrl, currentReplace, + oldState === $location.$$state ? null : $location.$$state); + } + afterLocationChange(oldUrl, oldState); } - afterLocationChange(oldUrl, oldState); - } - }); + }); + } else { + $browser.forceReloadLocationUpdate(newUrl); + previousOldUrl = previousNewUrl = false; + } + } else { + previousOldUrl = previousNewUrl = false; } $location.$$replace = false; diff --git a/src/ngMock/angular-mocks.js b/src/ngMock/angular-mocks.js index 562f88668966..c0af60b23012 100644 --- a/src/ngMock/angular-mocks.js +++ b/src/ngMock/angular-mocks.js @@ -23,17 +23,20 @@ angular.mock = {}; * that there are several helper methods available which can be used in tests. */ angular.mock.$BrowserProvider = function() { - this.$get = function() { - return new angular.mock.$Browser(); - }; + this.$get = ['$sniffer', function($sniffer) { + return new angular.mock.$Browser($sniffer); + }]; }; -angular.mock.$Browser = function() { +angular.mock.$Browser = function($sniffer) { var self = this; this.isMock = true; self.$$url = "http://server/"; self.$$lastUrl = self.$$url; // used by url polling fn + self.$$reloadLocation = null; + self.$$sniffer = $sniffer; + self.$$simulateLocationUpdate = false; self.pollFns = []; // TODO(vojta): remove this temporary api @@ -46,9 +49,11 @@ angular.mock.$Browser = function() { self.onUrlChange = function(listener) { self.pollFns.push( function() { - if (self.$$lastUrl !== self.$$url || self.$$state !== self.$$lastState) { - self.$$lastUrl = self.$$url; + var currUrl = self.$$reloadLocation || self.$$url; + if (self.$$simulateLocationUpdate || self.$$lastUrl !== currUrl || self.$$state !== self.$$lastState) { + self.$$lastUrl = currUrl; self.$$lastState = self.$$state; + self.$$simulateLocationUpdate = false; listener(self.$$url, self.$$state); } } @@ -150,20 +155,43 @@ angular.mock.$Browser.prototype = { state = null; } if (url) { + var sameState = this.$$lastState === state; + + if (this.$$lastUrl === url && (!this.$$sniffer.history || sameState)) { + return this; + } + + var index = this.$$lastUrl.indexOf('#'); + var lastUrlStripped = (index === -1 ? this.$$lastUrl : this.$$lastUrl.substr(0, index)); + index = url.indexOf('#'); + var urlStripped = (index === -1 ? url : url.substr(0, index)); + + this.$$lastState = angular.copy(state); + + var sameBase = this.$$lastUrl && lastUrlStripped === urlStripped; + if (this.$$sniffer.history && (!sameBase || !sameState)) { + // Native pushState serializes & copies the object; simulate it. + this.$$state = angular.copy(state); + this.$$lastState = this.$$state; + } + if (!sameBase) { + this.$$reloadLocation = url; + } this.$$url = url; - // Native pushState serializes & copies the object; simulate it. - this.$$state = angular.copy(state); + this.$$lastUrl = url; + this.$$simulateLocationUpdate = true; + return this; } - return this.$$url; + return this.$$reloadLocation || this.$$url; }, state: function() { return this.$$state; }, - cookies: function(name, value) { + cookies: function(name, value) { if (name) { if (angular.isUndefined(value)) { delete this.cookieHash[name]; @@ -184,6 +212,12 @@ angular.mock.$Browser.prototype = { notifyWhenNoOutstandingRequests: function(fn) { fn(); + }, + + forceReloadLocationUpdate: function(url) { + if (this.$$reloadLocation) { + this.$$reloadLocation = url; + } } }; diff --git a/test/ng/locationSpec.js b/test/ng/locationSpec.js index de0561d0c16f..c2ea57e588b2 100644 --- a/test/ng/locationSpec.js +++ b/test/ng/locationSpec.js @@ -650,13 +650,17 @@ describe('$location', function() { function initService(options) { return module(function($provide, $locationProvider) { $locationProvider.html5Mode(options.html5Mode); - $locationProvider.hashPrefix(options.hashPrefix); + if (options.hashPrefix) { + $locationProvider.hashPrefix(options.hashPrefix); + } $provide.value('$sniffer', {history: options.supportHistory}); }); } function initBrowser(options) { return function($browser) { - $browser.url(options.url); + if (options.url) { + $browser.url(options.url); + } $browser.$$baseHref = options.basePath; }; } @@ -702,7 +706,7 @@ describe('$location', function() { // location.href = '...' fires hashchange event synchronously, so it might happen inside $apply it('should not $apply when browser url changed inside $apply', inject( function($rootScope, $browser, $location) { - var OLD_URL = $browser.url(), + var OLD_URL = $location.absUrl(), NEW_URL = 'http://new.com/a/b#!/new'; @@ -718,7 +722,7 @@ describe('$location', function() { // location.href = '...' fires hashchange event synchronously, so it might happen inside $digest it('should not $apply when browser url changed inside $digest', inject( function($rootScope, $browser, $location) { - var OLD_URL = $browser.url(), + var OLD_URL = $location.absUrl(), NEW_URL = 'http://new.com/a/b#!/new', notRunYet = true; @@ -756,7 +760,12 @@ describe('$location', function() { }); $rootScope.$apply(); - expect($browserUrl).toHaveBeenCalledOnce(); + + // This test actually triggers a $digest loop inside the $locationWatch function in $location. + // The first $browser.url() set doesn't reset $browser.url()'s caching variable appropriately, + // so after the second call to it, logic kicks in to force an update of the caching variable. + // Hence, two calls to $browser.url()'s setter is exactly correct here. + expect($browserUrl.calls.length).toBe(2); expect($browser.url()).toBe('http://new.com/a/b#!/new/path?a=b'); })); @@ -993,7 +1002,7 @@ describe('$location', function() { inject( initBrowser({url:'http://domain.com/base/index.html',basePath: '/base/index.html'}), function($browser, $location) { - expect($browser.url()).toBe('http://domain.com/base/index.html#!/index.html'); + expect($location.absUrl()).toBe('http://domain.com/base/index.html#!/index.html'); } ); }); @@ -2278,4 +2287,198 @@ describe('$location', function() { throwOnState(location); }); }); + + + function initChangeSuccessListener($rootScope, $location, newPath) { + $rootScope.$on('$locationChangeSuccess', function(event, newUrl, oldUrl) { + $location.path(newPath); + }); + } + + describe('location watch for hashbang browsers', function() { + beforeEach(initService({html5Mode: true, supportHistory: false})); + beforeEach(inject(initBrowser({basePath: '/app/'}))); + + it('should not infinite $digest when going to base URL without trailing slash when $locationChangeSuccess watcher changes path to /Home', function() { + inject(function($rootScope, $injector, $browser) { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + var $browserForceReloadLocationUpdate = spyOn($browser, 'forceReloadLocationUpdate').andCallThrough(); + + $browser.url('http://server/app'); + $browser.poll(); + + var $location = $injector.get('$location'); + initChangeSuccessListener($rootScope, $location, '/Home'); + + $rootScope.$digest(); + + expect($browser.url()).toEqual('http://server/app/#/Home'); + expect($location.path()).toEqual('/Home'); + expect($browserUrl.calls.length).toEqual(3); + expect($browserForceReloadLocationUpdate).toHaveBeenCalledOnce(); + }); + }); + + it('should not infinite $digest when going to base URL without trailing slash when $locationChangeSuccess watcher changes path to /', function() { + inject(function($rootScope, $injector, $browser) { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + var $browserForceReloadLocationUpdate = spyOn($browser, 'forceReloadLocationUpdate').andCallThrough(); + + $browser.url('http://server/app'); + $browser.poll(); + + var $location = $injector.get('$location'); + initChangeSuccessListener($rootScope, $location, '/'); + + $rootScope.$digest(); + + expect($browser.url()).toEqual('http://server/app/#/'); + expect($location.path()).toEqual('/'); + expect($browserUrl.calls.length).toEqual(3); + expect($browserForceReloadLocationUpdate).toHaveBeenCalledOnce(); + }); + }); + + it('should not infinite $digest when going to base URL with trailing slash when $locationChangeSuccess watcher changes path to /Home', function() { + inject(function($rootScope, $injector, $browser) { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + var $browserForceReloadLocationUpdate = spyOn($browser, 'forceReloadLocationUpdate').andCallThrough(); + + $browser.url('http://server/app/'); + $browser.poll(); + + var $location = $injector.get('$location'); + initChangeSuccessListener($rootScope, $location, '/Home'); + + $rootScope.$digest(); + + expect($browser.url()).toEqual('http://server/app/#/Home'); + expect($location.path()).toEqual('/Home'); + expect($browserUrl.calls.length).toEqual(2); + expect($browserForceReloadLocationUpdate).toHaveBeenCalledOnce(); + }); + }); + + it('should not infinite $digest when going to base URL with trailing slash when $locationChangeSuccess watcher changes path to /', function() { + inject(function($rootScope, $injector, $browser) { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + var $browserForceReloadLocationUpdate = spyOn($browser, 'forceReloadLocationUpdate').andCallThrough(); + + $browser.url('http://server/app/'); + $browser.poll(); + + var $location = $injector.get('$location'); + initChangeSuccessListener($rootScope, $location, '/'); + + $rootScope.$digest(); + + expect($browser.url()).toEqual('http://server/app/#/'); + expect($location.path()).toEqual('/'); + expect($browserUrl.calls.length).toEqual(2); + expect($browserForceReloadLocationUpdate).toHaveBeenCalledOnce(); + }); + }); + }); + + + describe('location watch for HTML5 browsers', function() { + beforeEach(initService({html5Mode: true, supportHistory: true})); + beforeEach(inject(initBrowser({basePath: '/app/'}))); + + it('should not infinite $digest when going to base URL without trailing slash when $locationChangeSuccess watcher changes path to /Home', function() { + inject(function($rootScope, $injector, $browser) { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + var $browserForceReloadLocationUpdate = spyOn($browser, 'forceReloadLocationUpdate').andCallThrough(); + + $browser.url('http://server/app'); + $browser.poll(); + + var $location = $injector.get('$location'); + initChangeSuccessListener($rootScope, $location, '/Home'); + + $rootScope.$digest(); + + expect($browser.url()).toEqual('http://server/app/Home'); + expect($location.path()).toEqual('/Home'); + expect($browserUrl.calls.length).toEqual(3); + expect($browserForceReloadLocationUpdate).not.toHaveBeenCalled(); + }); + }); + + it('should not infinite $digest when going to base URL without trailing slash when $locationChangeSuccess watcher changes path to /', function() { + inject(function($rootScope, $injector, $browser) { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + var $browserForceReloadLocationUpdate = spyOn($browser, 'forceReloadLocationUpdate').andCallThrough(); + + $browser.url('http://server/app'); + $browser.poll(); + + var $location = $injector.get('$location'); + initChangeSuccessListener($rootScope, $location, '/'); + + $rootScope.$digest(); + + expect($browser.url()).toEqual('http://server/app/'); + expect($location.path()).toEqual('/'); + expect($browserUrl.calls.length).toEqual(2); + expect($browserForceReloadLocationUpdate).not.toHaveBeenCalled(); + }); + }); + + it('should not infinite $digest when going to base URL with trailing slash when $locationChangeSuccess watcher changes path to /Home', function() { + inject(function($rootScope, $injector, $browser) { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + var $browserForceReloadLocationUpdate = spyOn($browser, 'forceReloadLocationUpdate').andCallThrough(); + + $browser.url('http://server/app/'); + $browser.poll(); + + var $location = $injector.get('$location'); + initChangeSuccessListener($rootScope, $location, '/Home'); + + $rootScope.$digest(); + + expect($browser.url()).toEqual('http://server/app/Home'); + expect($location.path()).toEqual('/Home'); + expect($browserUrl.calls.length).toEqual(2); + expect($browserForceReloadLocationUpdate).not.toHaveBeenCalled(); + }); + }); + + it('should not infinite $digest when going to base URL with trailing slash when $locationChangeSuccess watcher changes path to /', function() { + inject(function($rootScope, $injector, $browser) { + var $browserUrl = spyOnlyCallsWithArgs($browser, 'url').andCallThrough(); + var $browserForceReloadLocationUpdate = spyOn($browser, 'forceReloadLocationUpdate').andCallThrough(); + + $browser.url('http://server/app/'); + $browser.poll(); + + var $location = $injector.get('$location'); + initChangeSuccessListener($rootScope, $location, '/'); + + $rootScope.$digest(); + + expect($browser.url()).toEqual('http://server/app/'); + expect($location.path()).toEqual('/'); + expect($browserUrl.calls.length).toEqual(1); + expect($browserForceReloadLocationUpdate).not.toHaveBeenCalled(); + }); + }); + }); + + + it('should not get caught in infinite digest when replacing empty path with slash', function() { + initService({html5Mode:true,supportHistory:false}); + initBrowser({url:'http://server/base', basePath:'/base/'}); + inject( + function($browser, $location, $rootScope, $window) { + $rootScope.$on('$locationChangeSuccess', function() { + if ($location.path() !== '/') { + $location.path('/').replace(); + } + }); + $rootScope.$digest(); + } + ); + }); });