From 988ed451b508b9d7ea4690b150993ec62d8a3743 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Wed, 22 Jun 2011 19:57:22 +0200 Subject: feat($browser): jQuery style url method, onUrlChange event This is just basic implementation of $browser.url, $browser.onUrlChange methods: $browser.url() - returns current location.href $browser.url('/new') - set url to /new If supported, history.pushState is used, location.href property otherwise. $browser.url('/new', true) - replace current url with /new If supported, history.replaceState is used, location.replace otherwise. $browser.onUrlChange is only fired when url is changed from the browser: - user types into address bar - user clicks on back/forward button - user clicks on link It's not fired when url is changed using $browser.url() Breaks Removed $browser.setUrl(), $browser.getUrl(), use $browser.url() Breaks Removed $browser.onHashChange(), use $browser.onUrlChange() --- src/AngularPublic.js | 3 +- src/Browser.js | 130 ++++++++++++++---------- src/angular-mocks.js | 23 ++--- src/service/location.js | 6 +- test/BrowserSpecs.js | 230 ++++++++++++++++++++++++++++++------------- test/ScenarioSpec.js | 2 +- test/service/locationSpec.js | 10 +- test/widgetsSpec.js | 2 +- 8 files changed, 262 insertions(+), 144 deletions(-) diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 476de3e3..f63948d8 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -4,8 +4,9 @@ var browserSingleton; angularService('$browser', function($log){ if (!browserSingleton) { + // TODO(vojta): inject $sniffer service when implemented browserSingleton = new Browser(window, jqLite(window.document), jqLite(window.document.body), - XHR, $log); + XHR, $log, {}); browserSingleton.bind(); } return browserSingleton; diff --git a/src/Browser.js b/src/Browser.js index f25e8a20..8e8f511a 100644 --- a/src/Browser.js +++ b/src/Browser.js @@ -34,15 +34,16 @@ var XHR = window.XMLHttpRequest || function () { * @param {object} body jQuery wrapped document.body. * @param {function()} XHR XMLHttpRequest constructor. * @param {object} $log console.log or an object with the same interface. + * @param {object} $sniffer $sniffer service */ -function Browser(window, document, body, XHR, $log) { +function Browser(window, document, body, XHR, $log, $sniffer) { var self = this, rawDocument = document[0], location = window.location, + history = window.history, setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, - pendingDeferIds = {}, - lastLocationUrl; + pendingDeferIds = {}; self.isMock = false; @@ -194,78 +195,103 @@ function Browser(window, document, body, XHR, $log) { // URL API ////////////////////////////////////////////////////////////// - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$browser#setUrl - * @methodOf angular.service.$browser - * - * @param {string} url New url - * - * @description - * Sets browser's url - */ - self.setUrl = function(url) { - - var existingURL = lastLocationUrl; - if (!existingURL.match(/#/)) existingURL += '#'; - if (!url.match(/#/)) url += '#'; - if (existingURL != url) { - location.href = url; - } - }; + var lastBrowserUrl = location.href; /** * @workInProgress * @ngdoc method - * @name angular.service.$browser#getUrl + * @name angular.service.$browser#url * @methodOf angular.service.$browser * * @description - * Get current browser's url + * GETTER: + * Without any argument, this method just returns current value of location.href. + * + * SETTER: + * With at least one argument, this method sets url to new value. + * If html5 history api supported, pushState/replaceState is used, otherwise + * location.href/location.replace is used. + * Returns its own instance to allow chaining * - * @returns {string} Browser's url + * NOTE: this api is intended for use only by the $location service. Please use the + * {@link angular.service.$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 ? */ - self.getUrl = function() { - return lastLocationUrl = location.href; + self.url = function(url, replace) { + // setter + if (url) { + lastBrowserUrl = url; + if ($sniffer.history) { + if (replace) history.replaceState(null, '', url); + else history.pushState(null, '', url); + } else { + if (replace) location.replace(url); + else location.href = url; + } + return self; + // getter + } else { + return location.href; + } }; + var urlChangeListeners = [], + urlChangeInit = false; + + function fireUrlChange() { + if (lastBrowserUrl == self.url()) return; + + lastBrowserUrl = self.url(); + forEach(urlChangeListeners, function(listener) { + listener(self.url()); + }); + } /** * @workInProgress * @ngdoc method - * @name angular.service.$browser#onHashChange + * @name angular.service.$browser#onUrlChange * @methodOf angular.service.$browser + * @TODO(vojta): refactor to use node's syntax for events * * @description - * Detects if browser support onhashchange events and register a listener otherwise registers - * $browser poller. The `listener` will then get called when the hash changes. + * Register callback function that will be called, when url changes. + * + * It's only called when the url is changed by outside of angular: + * - user types different url into address bar + * - user clicks on history (forward/back) button + * - user clicks on a link * - * The listener gets called with either HashChangeEvent object or simple object that also contains - * `oldURL` and `newURL` properties. + * It's not called when url is changed by $browser.url() method * - * Note: this api is intended for use only by the $location service. Please use the - * {@link angular.service.$location $location service} to monitor hash changes in angular apps. + * The listener gets called with new url as parameter. * - * @param {function(event)} listener Listener function to be called when url hash changes. - * @return {function()} Returns the registered listener fn - handy if the fn is anonymous. + * NOTE: this api is intended for use only by the $location service. Please use the + * {@link angular.service.$location $location service} to monitor url changes in angular apps. + * + * @param {function(string)} listener Listener function to be called when url changes. + * @return {function(string)} Returns the registered listener fn - handy if the fn is anonymous. */ - self.onHashChange = function(listener) { - // IE8 comp mode returns true, but doesn't support hashchange event - var dm = window.document.documentMode; - if ('onhashchange' in window && (isUndefined(dm) || dm >= 8)) { - jqLite(window).bind('hashchange', listener); - } else { - var lastBrowserUrl = self.getUrl(); - - self.addPollFn(function() { - if (lastBrowserUrl != self.getUrl()) { - listener(); - lastBrowserUrl = self.getUrl(); - } - }); + self.onUrlChange = function(callback) { + if (!urlChangeInit) { + // We listen on both (hashchange/popstate) when available, as some browsers (e.g. Opera) + // don't fire popstate when user change the address bar and don't fire hashchange when url + // changed by push/replaceState + + // html5 history api - popstate event + if ($sniffer.history) jqLite(window).bind('popstate', fireUrlChange); + // hashchange event + if ($sniffer.hashchange) jqLite(window).bind('hashchange', fireUrlChange); + // polling + else self.addPollFn(fireUrlChange); + + urlChangeInit = true; } - return listener; + + urlChangeListeners.push(callback); + return callback; }; ////////////////////////////////////////////////////////////// diff --git a/src/angular-mocks.js b/src/angular-mocks.js index 066ccea5..8410dc8c 100644 --- a/src/angular-mocks.js +++ b/src/angular-mocks.js @@ -89,19 +89,19 @@ function MockBrowser() { requests = []; this.isMock = true; - self.url = "http://server"; - self.lastUrl = self.url; // used by url polling fn + self.$$url = "http://server"; + self.$$lastUrl = self.$$url; // used by url polling fn self.pollFns = []; // register url polling fn - self.onHashChange = function(listener) { + self.onUrlChange = function(listener) { self.pollFns.push( function() { - if (self.lastUrl != self.url) { - self.lastUrl = self.url; - listener(); + if (self.$$lastUrl != self.$$url) { + self.$$lastUrl = self.$$url; + listener(self.$$url); } } ); @@ -326,12 +326,13 @@ MockBrowser.prototype = { hover: function(onHover) { }, - getUrl: function(){ - return this.url; - }, + url: function(url, replace) { + if (url) { + this.$$url = url; + return this; + } - setUrl: function(url){ - this.url = url; + return this.$$url; }, cookies: function(name, value) { diff --git a/src/service/location.js b/src/service/location.js index 6e646b8b..2f53f520 100644 --- a/src/service/location.js +++ b/src/service/location.js @@ -72,8 +72,8 @@ angularServiceInject("$location", function($browser) { var location = {update: update, updateHash: updateHash}; var lastLocation = {}; // last state since last update(). - $browser.onHashChange(bind(this, this.$apply, function() { //register - update($browser.getUrl()); + $browser.onUrlChange(bind(this, this.$apply, function() { //register + update($browser.url()); }))(); //initialize this.$watch(sync); @@ -120,7 +120,7 @@ angularServiceInject("$location", function($browser) { location.href = composeHref(location); } - $browser.setUrl(location.href); + $browser.url(location.href); copy(location, lastLocation); } diff --git a/test/BrowserSpecs.js b/test/BrowserSpecs.js index 5cc7ae65..e503a17d 100644 --- a/test/BrowserSpecs.js +++ b/test/BrowserSpecs.js @@ -2,7 +2,7 @@ describe('browser', function(){ - var browser, fakeWindow, xhr, logs, scripts, removedScripts, setTimeoutQueue; + var browser, fakeWindow, xhr, logs, scripts, removedScripts, setTimeoutQueue, sniffer; function fakeSetTimeout(fn) { return setTimeoutQueue.push(fn) - 1; //return position in the queue @@ -26,8 +26,32 @@ describe('browser', function(){ scripts = []; removedScripts = []; xhr = null; + sniffer = {history: true, hashchange: true}; + + // mock window, extract ? fakeWindow = { - location: {href:"http://server"}, + events: {}, + fire: function(name) { + forEach(this.events[name], function(listener) { + listener.apply(null, arguments); + }); + }, + addEventListener: function(name, listener) { + if (isUndefined(this.events[name])) { + this.events[name] = []; + } + this.events[name].push(listener); + }, + attachEvent: function(name, listener) { + if (isUndefined(this.events[name])) { + this.events[name] = []; + } + this.events[name].push(listener); + }, + removeEventListener: noop, + detachEvent: noop, + location: {href: 'http://server', replace: noop}, + history: {replaceState: noop, pushState: noop}, setTimeout: fakeSetTimeout, clearTimeout: fakeClearTimeout }; @@ -59,7 +83,7 @@ describe('browser', function(){ error: function() { logs.error.push(slice.call(arguments)); }}; browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, FakeXhr, - fakeLog); + fakeLog, sniffer); }); it('should contain cookie cruncher', function() { @@ -482,96 +506,162 @@ describe('browser', function(){ }); }); + describe('url', function() { + var pushState, replaceState, locationReplace; - describe('url api', function() { - it('should use $browser poller to detect url changes when onhashchange event is unsupported', - function() { + beforeEach(function() { + pushState = spyOn(fakeWindow.history, 'pushState'); + replaceState = spyOn(fakeWindow.history, 'replaceState'); + locationReplace = spyOn(fakeWindow.location, 'replace'); + }); - fakeWindow = { - location: {href:"http://server"}, - document: {}, - setTimeout: fakeSetTimeout - }; + it('should return current location.href', function() { + fakeWindow.location.href = 'http://test.com'; + expect(browser.url()).toEqual('http://test.com'); - browser = new Browser(fakeWindow, {}, {}); - browser.startPoller = function() {}; + fakeWindow.location.href = 'https://another.com'; + expect(browser.url()).toEqual('https://another.com'); + }); - var events = []; + it('should use history.pushState when available', function() { + sniffer.history = true; + browser.url('http://new.org'); - browser.onHashChange(function() { - events.push('x'); - }); + expect(pushState).toHaveBeenCalled(); + expect(pushState.argsForCall[0][2]).toEqual('http://new.org'); - fakeWindow.location.href = "http://server/#newHash"; - expect(events).toEqual([]); - fakeSetTimeout.flush(); - expect(events).toEqual(['x']); + expect(replaceState).not.toHaveBeenCalled(); + expect(locationReplace).not.toHaveBeenCalled(); + expect(fakeWindow.location.href).toEqual('http://server'); + }); - //don't do anything if url hasn't changed - events = []; - fakeSetTimeout.flush(); - expect(events).toEqual([]); + it('should use history.replaceState when available', function() { + sniffer.history = true; + browser.url('http://new.org', true); + + expect(replaceState).toHaveBeenCalled(); + expect(replaceState.argsForCall[0][2]).toEqual('http://new.org'); + + expect(pushState).not.toHaveBeenCalled(); + expect(locationReplace).not.toHaveBeenCalled(); + expect(fakeWindow.location.href).toEqual('http://server'); }); + it('should set location.href when pushState not available', function() { + sniffer.history = false; + browser.url('http://new.org'); - it('should use onhashchange events to detect url changes when supported by browser', - function() { + expect(fakeWindow.location.href).toEqual('http://new.org'); - var onHashChngListener; + expect(pushState).not.toHaveBeenCalled(); + expect(replaceState).not.toHaveBeenCalled(); + expect(locationReplace).not.toHaveBeenCalled(); + }); - fakeWindow = {location: {href:"http://server"}, - addEventListener: function(type, listener) { - expect(type).toEqual('hashchange'); - onHashChngListener = listener; - }, - attachEvent: function(type, listener) { - expect(type).toEqual('onhashchange'); - onHashChngListener = listener; - }, - removeEventListener: angular.noop, - detachEvent: angular.noop, - document: {} - }; - fakeWindow.onhashchange = true; + it('should use location.replace when history.replaceState not available', function() { + sniffer.history = false; + browser.url('http://new.org', true); - browser = new Browser(fakeWindow, {}, {}); + expect(locationReplace).toHaveBeenCalledWith('http://new.org'); - var events = [], - event = {type: "hashchange"}; + expect(pushState).not.toHaveBeenCalled(); + expect(replaceState).not.toHaveBeenCalled(); + expect(fakeWindow.location.href).toEqual('http://server'); + }); - browser.onHashChange(function(e) { - events.push(e); - }); + it('should return $browser to allow chaining', function() { + expect(browser.url('http://any.com')).toBe(browser); + }); + }); - expect(events).toEqual([]); - onHashChngListener(event); + describe('urlChange', function() { + var callback; - expect(events.length).toBe(1); - expect(events[0].originalEvent || events[0]).toBe(event); // please jQuery and jqLite + beforeEach(function() { + callback = jasmine.createSpy('onUrlChange'); + }); - // clean up the jqLite cache so that the global afterEach doesn't complain - if (!jQuery) { - jqLite(fakeWindow).dealoc(); - } + afterEach(function() { + if (!jQuery) jqLite(fakeWindow).dealoc(); }); - // asynchronous test - it('should fire onHashChange when location.hash change', function() { - var callback = jasmine.createSpy('onHashChange'); - browser = new Browser(window, {}, {}); - browser.onHashChange(callback); + it('should return registered callback', function() { + expect(browser.onUrlChange(callback)).toBe(callback); + }); - window.location.hash = 'new-hash'; - browser.addPollFn(function() {}); + it('should forward popstate event with new url when history supported', function() { + sniffer.history = true; + browser.onUrlChange(callback); + fakeWindow.location.href = 'http://server/new'; - waitsFor(function() { - return callback.callCount; - }, 'onHashChange callback to be called', 1000); + fakeWindow.fire('popstate'); + expect(callback).toHaveBeenCalledWith('http://server/new'); - runs(function() { - if (!jQuery) jqLite(window).dealoc(); - window.location.hash = ''; - }); + fakeWindow.fire('hashchange'); + fakeSetTimeout.flush(); + expect(callback.callCount).toBe(1); + }); + + it('should forward only popstate event when both history and hashchange supported', function() { + sniffer.history = true; + sniffer.hashchange = true; + browser.onUrlChange(callback); + fakeWindow.location.href = 'http://server/new'; + + fakeWindow.fire('popstate'); + expect(callback).toHaveBeenCalledWith('http://server/new'); + + fakeWindow.fire('hashchange'); + fakeSetTimeout.flush(); + expect(callback.callCount).toBe(1); + }); + + it('should forward hashchange event with new url when only hashchange supported', function() { + sniffer.history = false; + sniffer.hashchange = true; + browser.onUrlChange(callback); + fakeWindow.location.href = 'http://server/new'; + + fakeWindow.fire('hashchange'); + expect(callback).toHaveBeenCalledWith('http://server/new'); + + fakeWindow.fire('popstate'); + fakeSetTimeout.flush(); + expect(callback.callCount).toBe(1); + }); + + it('should use polling when neither history nor hashchange supported', function() { + sniffer.history = false; + sniffer.hashchange = false; + browser.onUrlChange(callback); + + fakeWindow.location.href = 'http://server.new'; + fakeSetTimeout.flush(); + expect(callback).toHaveBeenCalledWith('http://server.new'); + + fakeWindow.fire('popstate'); + fakeWindow.fire('hashchange'); + expect(callback.callCount).toBe(1); + }); + + it('should not fire urlChange if changed by browser.url method (polling)', function() { + sniffer.history = false; + sniffer.hashchange = false; + browser.onUrlChange(callback); + browser.url('http://new.com'); + + fakeSetTimeout.flush(); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not fire urlChange if changed by browser.url method (hashchange)', function() { + sniffer.history = false; + sniffer.hashchange = true; + browser.onUrlChange(callback); + browser.url('http://new.com'); + + fakeWindow.fire('hashchange'); + expect(callback).not.toHaveBeenCalled(); }); }); diff --git a/test/ScenarioSpec.js b/test/ScenarioSpec.js index e91e0b98..54c99f77 100644 --- a/test/ScenarioSpec.js +++ b/test/ScenarioSpec.js @@ -40,7 +40,7 @@ describe("ScenarioSpec: Compilation", function(){ var $location = scope.$service('$location'); var $browser = scope.$service('$browser'); expect($location.hashSearch.book).toBeUndefined(); - $browser.setUrl(url); + $browser.url(url); $browser.poll(); expect($location.hashSearch.book).toEqual('moby'); }); diff --git a/test/service/locationSpec.js b/test/service/locationSpec.js index 5839b0b6..a137149d 100644 --- a/test/service/locationSpec.js +++ b/test/service/locationSpec.js @@ -32,24 +32,24 @@ describe('$location', function() { it('should update location when browser url changed', function() { var origUrl = $location.href; - expect(origUrl).toEqual($browser.getUrl()); + expect(origUrl).toEqual($browser.url()); var newUrl = 'http://somenew/url#foo'; - $browser.setUrl(newUrl); + $browser.url(newUrl); $browser.poll(); expect($location.href).toEqual(newUrl); }); it('should update browser at the end of $eval', function() { - var origBrowserUrl = $browser.getUrl(); + var origBrowserUrl = $browser.url(); $location.update('http://www.angularjs.org/'); $location.update({path: '/a/b'}); expect($location.href).toEqual('http://www.angularjs.org/a/b'); - expect($browser.getUrl()).toEqual('http://www.angularjs.org/a/b'); + expect($browser.url()).toEqual('http://www.angularjs.org/a/b'); $location.path = '/c'; scope.$digest(); - expect($browser.getUrl()).toEqual('http://www.angularjs.org/c'); + expect($browser.url()).toEqual('http://www.angularjs.org/c'); }); diff --git a/test/widgetsSpec.js b/test/widgetsSpec.js index 42220201..dd23ed77 100644 --- a/test/widgetsSpec.js +++ b/test/widgetsSpec.js @@ -1183,7 +1183,7 @@ describe("widget", function(){ var myApp = angular.scope(); var $browser = myApp.$service('$browser'); $browser.xhr.expectGET('includePartial.html').respond('view: '); - $browser.setUrl('http://server/#/foo'); + $browser.url('http://server/#/foo'); var $route = myApp.$service('$route'); $route.when('/foo', {controller: angular.noop, template: 'viewPartial.html'}); -- cgit v1.2.3