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()
This commit is contained in:
Vojta Jina 2011-06-22 19:57:22 +02:00
parent fc2f188d4d
commit 988ed451b5
8 changed files with 276 additions and 158 deletions

View file

@ -4,8 +4,9 @@ var browserSingleton;
angularService('$browser', function($log){ angularService('$browser', function($log){
if (!browserSingleton) { if (!browserSingleton) {
// TODO(vojta): inject $sniffer service when implemented
browserSingleton = new Browser(window, jqLite(window.document), jqLite(window.document.body), browserSingleton = new Browser(window, jqLite(window.document), jqLite(window.document.body),
XHR, $log); XHR, $log, {});
browserSingleton.bind(); browserSingleton.bind();
} }
return browserSingleton; return browserSingleton;

View file

@ -34,15 +34,16 @@ var XHR = window.XMLHttpRequest || function () {
* @param {object} body jQuery wrapped document.body. * @param {object} body jQuery wrapped document.body.
* @param {function()} XHR XMLHttpRequest constructor. * @param {function()} XHR XMLHttpRequest constructor.
* @param {object} $log console.log or an object with the same interface. * @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, var self = this,
rawDocument = document[0], rawDocument = document[0],
location = window.location, location = window.location,
history = window.history,
setTimeout = window.setTimeout, setTimeout = window.setTimeout,
clearTimeout = window.clearTimeout, clearTimeout = window.clearTimeout,
pendingDeferIds = {}, pendingDeferIds = {};
lastLocationUrl;
self.isMock = false; self.isMock = false;
@ -194,78 +195,103 @@ function Browser(window, document, body, XHR, $log) {
// URL API // URL API
////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////
var lastBrowserUrl = location.href;
/** /**
* @workInProgress * @workInProgress
* @ngdoc method * @ngdoc method
* @name angular.service.$browser#setUrl * @name angular.service.$browser#url
* @methodOf angular.service.$browser * @methodOf angular.service.$browser
* *
* @param {string} url New url
*
* @description * @description
* Sets 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
*
* 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.setUrl = function(url) { self.url = function(url, replace) {
// setter
var existingURL = lastLocationUrl; if (url) {
if (!existingURL.match(/#/)) existingURL += '#'; lastBrowserUrl = url;
if (!url.match(/#/)) url += '#'; if ($sniffer.history) {
if (existingURL != url) { if (replace) history.replaceState(null, '', url);
location.href = url; else history.pushState(null, '', url);
} else {
if (replace) location.replace(url);
else location.href = url;
}
return self;
// getter
} else {
return location.href;
} }
};
/**
* @workInProgress
* @ngdoc method
* @name angular.service.$browser#getUrl
* @methodOf angular.service.$browser
*
* @description
* Get current browser's url
*
* @returns {string} Browser's url
*/
self.getUrl = function() {
return lastLocationUrl = location.href;
}; };
var urlChangeListeners = [],
urlChangeInit = false;
function fireUrlChange() {
if (lastBrowserUrl == self.url()) return;
lastBrowserUrl = self.url();
forEach(urlChangeListeners, function(listener) {
listener(self.url());
});
}
/** /**
* @workInProgress * @workInProgress
* @ngdoc method * @ngdoc method
* @name angular.service.$browser#onHashChange * @name angular.service.$browser#onUrlChange
* @methodOf angular.service.$browser * @methodOf angular.service.$browser
* @TODO(vojta): refactor to use node's syntax for events
* *
* @description * @description
* Detects if browser support onhashchange events and register a listener otherwise registers * Register callback function that will be called, when url changes.
* $browser poller. The `listener` will then get called when the hash changes.
* *
* The listener gets called with either HashChangeEvent object or simple object that also contains * It's only called when the url is changed by outside of angular:
* `oldURL` and `newURL` properties. * - user types different url into address bar
* - user clicks on history (forward/back) button
* - user clicks on a link
* *
* Note: this api is intended for use only by the $location service. Please use the * It's not called when url is changed by $browser.url() method
* {@link angular.service.$location $location service} to monitor hash changes in angular apps.
* *
* @param {function(event)} listener Listener function to be called when url hash changes. * The listener gets called with new url as parameter.
* @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) { self.onUrlChange = function(callback) {
// IE8 comp mode returns true, but doesn't support hashchange event if (!urlChangeInit) {
var dm = window.document.documentMode; // We listen on both (hashchange/popstate) when available, as some browsers (e.g. Opera)
if ('onhashchange' in window && (isUndefined(dm) || dm >= 8)) { // don't fire popstate when user change the address bar and don't fire hashchange when url
jqLite(window).bind('hashchange', listener); // changed by push/replaceState
} else {
var lastBrowserUrl = self.getUrl();
self.addPollFn(function() { // html5 history api - popstate event
if (lastBrowserUrl != self.getUrl()) { if ($sniffer.history) jqLite(window).bind('popstate', fireUrlChange);
listener(); // hashchange event
lastBrowserUrl = self.getUrl(); if ($sniffer.hashchange) jqLite(window).bind('hashchange', fireUrlChange);
} // polling
}); else self.addPollFn(fireUrlChange);
urlChangeInit = true;
} }
return listener;
urlChangeListeners.push(callback);
return callback;
}; };
////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////

23
src/angular-mocks.js vendored
View file

@ -89,19 +89,19 @@ function MockBrowser() {
requests = []; requests = [];
this.isMock = true; this.isMock = true;
self.url = "http://server"; self.$$url = "http://server";
self.lastUrl = self.url; // used by url polling fn self.$$lastUrl = self.$$url; // used by url polling fn
self.pollFns = []; self.pollFns = [];
// register url polling fn // register url polling fn
self.onHashChange = function(listener) { self.onUrlChange = function(listener) {
self.pollFns.push( self.pollFns.push(
function() { function() {
if (self.lastUrl != self.url) { if (self.$$lastUrl != self.$$url) {
self.lastUrl = self.url; self.$$lastUrl = self.$$url;
listener(); listener(self.$$url);
} }
} }
); );
@ -326,12 +326,13 @@ MockBrowser.prototype = {
hover: function(onHover) { hover: function(onHover) {
}, },
getUrl: function(){ url: function(url, replace) {
return this.url; if (url) {
}, this.$$url = url;
return this;
}
setUrl: function(url){ return this.$$url;
this.url = url;
}, },
cookies: function(name, value) { cookies: function(name, value) {

View file

@ -72,8 +72,8 @@ angularServiceInject("$location", function($browser) {
var location = {update: update, updateHash: updateHash}; var location = {update: update, updateHash: updateHash};
var lastLocation = {}; // last state since last update(). var lastLocation = {}; // last state since last update().
$browser.onHashChange(bind(this, this.$apply, function() { //register $browser.onUrlChange(bind(this, this.$apply, function() { //register
update($browser.getUrl()); update($browser.url());
}))(); //initialize }))(); //initialize
this.$watch(sync); this.$watch(sync);
@ -120,7 +120,7 @@ angularServiceInject("$location", function($browser) {
location.href = composeHref(location); location.href = composeHref(location);
} }
$browser.setUrl(location.href); $browser.url(location.href);
copy(location, lastLocation); copy(location, lastLocation);
} }

View file

@ -2,7 +2,7 @@
describe('browser', function(){ describe('browser', function(){
var browser, fakeWindow, xhr, logs, scripts, removedScripts, setTimeoutQueue; var browser, fakeWindow, xhr, logs, scripts, removedScripts, setTimeoutQueue, sniffer;
function fakeSetTimeout(fn) { function fakeSetTimeout(fn) {
return setTimeoutQueue.push(fn) - 1; //return position in the queue return setTimeoutQueue.push(fn) - 1; //return position in the queue
@ -26,8 +26,32 @@ describe('browser', function(){
scripts = []; scripts = [];
removedScripts = []; removedScripts = [];
xhr = null; xhr = null;
sniffer = {history: true, hashchange: true};
// mock window, extract ?
fakeWindow = { 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, setTimeout: fakeSetTimeout,
clearTimeout: fakeClearTimeout clearTimeout: fakeClearTimeout
}; };
@ -59,7 +83,7 @@ describe('browser', function(){
error: function() { logs.error.push(slice.call(arguments)); }}; error: function() { logs.error.push(slice.call(arguments)); }};
browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, FakeXhr, browser = new Browser(fakeWindow, jqLite(window.document), fakeBody, FakeXhr,
fakeLog); fakeLog, sniffer);
}); });
it('should contain cookie cruncher', function() { it('should contain cookie cruncher', function() {
@ -482,96 +506,162 @@ describe('browser', function(){
}); });
}); });
describe('url', function() {
var pushState, replaceState, locationReplace;
describe('url api', function() { beforeEach(function() {
it('should use $browser poller to detect url changes when onhashchange event is unsupported', pushState = spyOn(fakeWindow.history, 'pushState');
function() { replaceState = spyOn(fakeWindow.history, 'replaceState');
locationReplace = spyOn(fakeWindow.location, 'replace');
fakeWindow = {
location: {href:"http://server"},
document: {},
setTimeout: fakeSetTimeout
};
browser = new Browser(fakeWindow, {}, {});
browser.startPoller = function() {};
var events = [];
browser.onHashChange(function() {
events.push('x');
});
fakeWindow.location.href = "http://server/#newHash";
expect(events).toEqual([]);
fakeSetTimeout.flush();
expect(events).toEqual(['x']);
//don't do anything if url hasn't changed
events = [];
fakeSetTimeout.flush();
expect(events).toEqual([]);
}); });
it('should return current location.href', function() {
fakeWindow.location.href = 'http://test.com';
expect(browser.url()).toEqual('http://test.com');
it('should use onhashchange events to detect url changes when supported by browser', fakeWindow.location.href = 'https://another.com';
function() { expect(browser.url()).toEqual('https://another.com');
var onHashChngListener;
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;
browser = new Browser(fakeWindow, {}, {});
var events = [],
event = {type: "hashchange"};
browser.onHashChange(function(e) {
events.push(e);
});
expect(events).toEqual([]);
onHashChngListener(event);
expect(events.length).toBe(1);
expect(events[0].originalEvent || events[0]).toBe(event); // please jQuery and jqLite
// clean up the jqLite cache so that the global afterEach doesn't complain
if (!jQuery) {
jqLite(fakeWindow).dealoc();
}
}); });
// asynchronous test it('should use history.pushState when available', function() {
it('should fire onHashChange when location.hash change', function() { sniffer.history = true;
var callback = jasmine.createSpy('onHashChange'); browser.url('http://new.org');
browser = new Browser(window, {}, {});
browser.onHashChange(callback);
window.location.hash = 'new-hash'; expect(pushState).toHaveBeenCalled();
browser.addPollFn(function() {}); expect(pushState.argsForCall[0][2]).toEqual('http://new.org');
waitsFor(function() { expect(replaceState).not.toHaveBeenCalled();
return callback.callCount; expect(locationReplace).not.toHaveBeenCalled();
}, 'onHashChange callback to be called', 1000); expect(fakeWindow.location.href).toEqual('http://server');
});
runs(function() { it('should use history.replaceState when available', function() {
if (!jQuery) jqLite(window).dealoc(); sniffer.history = true;
window.location.hash = ''; 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');
expect(fakeWindow.location.href).toEqual('http://new.org');
expect(pushState).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
expect(locationReplace).not.toHaveBeenCalled();
});
it('should use location.replace when history.replaceState not available', function() {
sniffer.history = false;
browser.url('http://new.org', true);
expect(locationReplace).toHaveBeenCalledWith('http://new.org');
expect(pushState).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
expect(fakeWindow.location.href).toEqual('http://server');
});
it('should return $browser to allow chaining', function() {
expect(browser.url('http://any.com')).toBe(browser);
});
});
describe('urlChange', function() {
var callback;
beforeEach(function() {
callback = jasmine.createSpy('onUrlChange');
});
afterEach(function() {
if (!jQuery) jqLite(fakeWindow).dealoc();
});
it('should return registered callback', function() {
expect(browser.onUrlChange(callback)).toBe(callback);
});
it('should forward popstate event with new url when history supported', function() {
sniffer.history = 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 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();
}); });
}); });

View file

@ -40,7 +40,7 @@ describe("ScenarioSpec: Compilation", function(){
var $location = scope.$service('$location'); var $location = scope.$service('$location');
var $browser = scope.$service('$browser'); var $browser = scope.$service('$browser');
expect($location.hashSearch.book).toBeUndefined(); expect($location.hashSearch.book).toBeUndefined();
$browser.setUrl(url); $browser.url(url);
$browser.poll(); $browser.poll();
expect($location.hashSearch.book).toEqual('moby'); expect($location.hashSearch.book).toEqual('moby');
}); });

View file

@ -32,24 +32,24 @@ describe('$location', function() {
it('should update location when browser url changed', function() { it('should update location when browser url changed', function() {
var origUrl = $location.href; var origUrl = $location.href;
expect(origUrl).toEqual($browser.getUrl()); expect(origUrl).toEqual($browser.url());
var newUrl = 'http://somenew/url#foo'; var newUrl = 'http://somenew/url#foo';
$browser.setUrl(newUrl); $browser.url(newUrl);
$browser.poll(); $browser.poll();
expect($location.href).toEqual(newUrl); expect($location.href).toEqual(newUrl);
}); });
it('should update browser at the end of $eval', function() { 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('http://www.angularjs.org/');
$location.update({path: '/a/b'}); $location.update({path: '/a/b'});
expect($location.href).toEqual('http://www.angularjs.org/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'; $location.path = '/c';
scope.$digest(); scope.$digest();
expect($browser.getUrl()).toEqual('http://www.angularjs.org/c'); expect($browser.url()).toEqual('http://www.angularjs.org/c');
}); });

View file

@ -1183,7 +1183,7 @@ describe("widget", function(){
var myApp = angular.scope(); var myApp = angular.scope();
var $browser = myApp.$service('$browser'); var $browser = myApp.$service('$browser');
$browser.xhr.expectGET('includePartial.html').respond('view: <ng:view></ng:view>'); $browser.xhr.expectGET('includePartial.html').respond('view: <ng:view></ng:view>');
$browser.setUrl('http://server/#/foo'); $browser.url('http://server/#/foo');
var $route = myApp.$service('$route'); var $route = myApp.$service('$route');
$route.when('/foo', {controller: angular.noop, template: 'viewPartial.html'}); $route.when('/foo', {controller: angular.noop, template: 'viewPartial.html'});