From 041fe475d0d45e082e779721fd36dbe15f5bf08c 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 state and title in pushState Adds $location pushState & replaceState methods acting as proxies to history pushState & replaceState methods. This allows using pushState to change state and title as well as URL. Note that these methods are not compatible with browsers not supporting the HTML5 History API, namely IE<10. Closes #3325 --- src/ng/browser.js | 33 +++++++++++------ src/ng/location.js | 69 ++++++++++++++++++++++++++++------ test/ng/browserSpecs.js | 78 ++++++++++++++++++++++++++++++++------- test/ng/locationSpec.js | 2 +- test/ngRoute/routeSpec.js | 2 +- 5 files changed, 146 insertions(+), 38 deletions(-) diff --git a/src/ng/browser.js b/src/ng/browser.js index f9502cd4a09b..23c921985c1c 100644 --- a/src/ng/browser.js +++ b/src/ng/browser.js @@ -123,6 +123,7 @@ function Browser(window, document, $log, $sniffer) { ////////////////////////////////////////////////////////////// var lastBrowserUrl = location.href, + lastBrowserState = history.state, baseElement = document.find('base'), newLocation = null; @@ -143,21 +144,30 @@ 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 + * @param {string=} title to use with pushState/replaceState */ - self.url = function(url, replace) { + self.url = function(url, replace, state, title) { // 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; + if (lastBrowserUrl == url && + // if pushState supported, check if state changed + ((lastBrowserState == null && state == null) || equals(lastBrowserState, state))) { + return; + } lastBrowserUrl = url; + lastBrowserState = copy(state); if ($sniffer.history) { - if (replace) history.replaceState(null, '', url); + title = title || rawDocument.title; + state = state || null; + if (replace) history.replaceState(state, title, url); else { - history.pushState(null, '', url); + history.pushState(state, title, url); // Crazy Opera Bug: http://my.opera.com/community/forums/topic.dml?id=1185462 baseElement.attr('href', baseElement.attr('href')); } @@ -170,13 +180,12 @@ function Browser(window, document, $log, $sniffer) { } } return self; - // getter - } else { - // - newLocation is a workaround for an IE7-9 issue with location.replace and location.href - // methods not updating location.href synchronously. - // - the replacement is a workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=407172 - return newLocation || location.href.replace(/%27/g,"'"); } + // getter + // - newLocation is a workaround for an IE7-9 issue with location.replace and location.href + // methods not updating location.href synchronously. + // - the replacement is a workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=407172 + return newLocation || location.href.replace(/%27/g,"'"); }; var urlChangeListeners = [], @@ -188,7 +197,7 @@ function Browser(window, document, $log, $sniffer) { lastBrowserUrl = self.url(); forEach(urlChangeListeners, function(listener) { - listener(self.url()); + listener(self.url(), history.state, rawDocument.title); }); } diff --git a/src/ng/location.js b/src/ng/location.js index 9bb4d417cfe2..7a80015f1b4b 100644 --- a/src/ng/location.js +++ b/src/ng/location.js @@ -282,11 +282,17 @@ LocationHashbangInHtml5Url.prototype = $$html5: false, /** - * Has any change been replacing ? + * Has any change been replacing? * @private */ $$replace: false, + /** + * Current History API state. + * @private + */ + $$state: null, + /** * @ngdoc method * @name $location#absUrl @@ -328,6 +334,43 @@ LocationHashbangInHtml5Url.prototype = return this; }, + /** + * @ngdoc method + * @name $location#pushState + * + * @description + * Invokes history.pushState. Changes url, state and title. + * + * @param {object=} state object for pushState + * @param {string=} title for pushState + * @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`) + * @return {object} $location + */ + pushState: function(state, title, url, replace) { + this.$$state = copy(state); + this.$$title = title; + this.$$replace = replace; + this.url(url, replace); + return this; + }, + + /** + * @ngdoc method + * @name $location#replaceState. + * + * @description + * Invokes history.replaceState. Changes url, state and title. + * + * @param {object=} state object for replaceState + * @param {string=} title for replaceState + * @param {string=} url New url without base prefix (e.g. `/path?a=b#hash`) + * @return {object} $location + */ + replaceState: function(state, title, url) { + this.pushState(state, title, url, true); + return this; + }, + /** * @ngdoc method * @name $location#protocol @@ -642,7 +685,7 @@ function $LocationProvider(){ } // update $location when $browser url changes - $browser.onUrlChange(function(newUrl) { + $browser.onUrlChange(function(newUrl, state, title) { if ($location.absUrl() != newUrl) { $rootScope.$evalAsync(function() { var oldUrl = $location.absUrl(); @@ -651,9 +694,9 @@ function $LocationProvider(){ if ($rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl).defaultPrevented) { $location.$$parse(oldUrl); - $browser.url(oldUrl); + $browser.url($location.absUrl(), false, state, title); } else { - afterLocationChange(oldUrl); + afterLocationChange(oldUrl, state, title); } }); if (!$rootScope.$$phase) $rootScope.$digest(); @@ -663,30 +706,34 @@ function $LocationProvider(){ // update browser var changeCounter = 0; $rootScope.$watch(function $locationWatch() { - var oldUrl = $browser.url(); - var currentReplace = $location.$$replace; + var oldUrl = $browser.url(), + currentReplace = $location.$$replace, + currentState = $location.$$state, + currentTitle = $location.$$title; if (!changeCounter || oldUrl != $location.absUrl()) { changeCounter++; $rootScope.$evalAsync(function() { - if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl). + if ($rootScope.$broadcast('$locationChangeStart', $location.absUrl(), oldUrl, currentState, currentTitle). defaultPrevented) { $location.$$parse(oldUrl); } else { - $browser.url($location.absUrl(), currentReplace); - afterLocationChange(oldUrl); + $browser.url($location.absUrl(), currentReplace, currentState, currentTitle); + afterLocationChange(oldUrl, currentState, currentTitle); } }); } $location.$$replace = false; + delete $location.$$state; + delete $location.$$title; return changeCounter; }); return $location; - function afterLocationChange(oldUrl) { - $rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl); + function afterLocationChange(oldUrl, state, title) { + $rootScope.$broadcast('$locationChangeSuccess', $location.absUrl(), oldUrl, state, title); } }]; } diff --git a/test/ng/browserSpecs.js b/test/ng/browserSpecs.js index 4157ecbdb7fd..184955450438 100755 --- a/test/ng/browserSpecs.js +++ b/test/ng/browserSpecs.js @@ -1,8 +1,11 @@ 'use strict'; +var sniffer = {}; + function MockWindow() { var events = {}; var timeouts = this.timeouts = []; + var mockWindow = this; this.setTimeout = function(fn) { return timeouts.push(fn) - 1; @@ -37,19 +40,32 @@ function MockWindow() { this.location = { href: 'http://server', - replace: noop + replace: function(url) { + this.href = url; + }, + }; + + this.document = { + title: '' }; this.history = { - replaceState: noop, - pushState: noop + state: null, + pushState: function(state, title, url) { + mockWindow.location.href = url; + mockWindow.history.state = copy(state); + mockWindow.document.title = title; + }, + replaceState: function() { + this.pushState.apply(this, arguments); + } }; } function MockDocument() { var self = this; - this[0] = window.document + this[0] = window.document; this.basePath = '/'; this.find = function(name) { @@ -71,7 +87,7 @@ function MockDocument() { describe('browser', function() { - var browser, fakeWindow, fakeDocument, logs, scripts, removedScripts, sniffer; + var browser, fakeWindow, fakeDocument, logs, scripts, removedScripts; beforeEach(function() { scripts = []; @@ -80,9 +96,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)); }, @@ -470,6 +483,45 @@ describe('browser', function() { }); }); + describe('pushState & replaceState state & title handling', 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 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 not 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({prop: 'val1'}); + }); + + it('should not 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: 'val3'}); + }); + }); + describe('urlChange', function() { var callback; @@ -491,7 +543,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(); @@ -505,7 +557,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(); @@ -519,7 +571,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(); @@ -533,7 +585,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(); @@ -554,7 +606,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'); diff --git a/test/ng/locationSpec.js b/test/ng/locationSpec.js index ff823d306efd..c49bd486e38e 100644 --- a/test/ng/locationSpec.js +++ b/test/ng/locationSpec.js @@ -567,7 +567,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, undefined]); expect($location.$$replace).toBe(false); })); diff --git a/test/ngRoute/routeSpec.js b/test/ngRoute/routeSpec.js index de7ccb8d593d..64ccb1a1d62b 100644 --- a/test/ngRoute/routeSpec.js +++ b/test/ngRoute/routeSpec.js @@ -865,7 +865,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, undefined]); }); }); });