src/ng/browser.js
'use strict';
/* global getHash: true, stripHash: false */
function getHash(url) {
var index = url.indexOf('#');
return index === -1 ? '' : url.substr(index);
}
function trimEmptyHash(url) {
return url.replace(/#$/, '');
}
/**
* ! This is a private undocumented service !
*
* @name $browser
* @requires $log
* @description
* This object has two goals:
*
* - hide all the global state in the browser caused by the window object
* - abstract away all the browser specific features and inconsistencies
*
* For tests we provide {@link ngMock.$browser mock implementation} of the `$browser`
* service, which can be used for convenient testing of the application without the interaction with
* the real browser apis.
*/
/**
* @param {object} window The global window object.
* @param {object} document jQuery wrapped document.
* @param {object} $log window.console or an object with the same interface.
* @param {object} $sniffer $sniffer service
*/
function Browser(window, document, $log, $sniffer, $$taskTrackerFactory) {
var self = this,
location = window.location,
history = window.history,
setTimeout = window.setTimeout,
clearTimeout = window.clearTimeout,
pendingDeferIds = {},
taskTracker = $$taskTrackerFactory($log);
self.isMock = false;
//////////////////////////////////////////////////////////////
// Task-tracking API
//////////////////////////////////////////////////////////////
// TODO(vojta): remove this temporary api
self.$$completeOutstandingRequest = taskTracker.completeTask;
self.$$incOutstandingRequestCount = taskTracker.incTaskCount;
// TODO(vojta): prefix this method with $$ ?
self.notifyWhenNoOutstandingRequests = taskTracker.notifyWhenNoPendingTasks;
//////////////////////////////////////////////////////////////
// URL API
//////////////////////////////////////////////////////////////
var cachedState, lastHistoryState,
lastBrowserUrl = location.href,
baseElement = document.find('base'),
pendingLocation = null,
getCurrentState = !$sniffer.history ? noop : function getCurrentState() {
try {
return history.state;
} catch (e) {
// MSIE can reportedly throw when there is no state (UNCONFIRMED).
}
};
cacheState();
/**
* @name $browser#url
*
* @description
* GETTER:
* Without any argument, this method just returns current value of `location.href` (with a
* trailing `#` stripped of if the hash is empty).
*
* 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 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 {object=} state State object to use with `pushState`/`replaceState`
*/
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) {
var sameState = lastHistoryState === state;
// Normalize the inputted URL
url = urlResolve(url).href;
// 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 || sameState)) {
return self;
}
var sameBase = lastBrowserUrl && stripHash(lastBrowserUrl) === stripHash(url);
lastBrowserUrl = url;
lastHistoryState = state;
// 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 ($sniffer.history && (!sameBase || !sameState)) {
history[replace ? 'replaceState' : 'pushState'](state, '', url);
cacheState();
} else {
if (!sameBase) {
pendingLocation = url;
}
if (replace) {
location.replace(url);
} else if (!sameBase) {
location.href = url;
} else {
location.hash = getHash(url);
}
if (location.href !== url) {
pendingLocation = url;
}
}
if (pendingLocation) {
pendingLocation = url;
}
return self;
// getter
} else {
// - pendingLocation is needed as browsers don't allow to read out
// the new location.href if a reload happened or if there is a bug like in iOS 9 (see
// https://openradar.appspot.com/22186109).
return trimEmptyHash(pendingLocation || location.href);
}
};
/**
* @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 cachedState;
};
var urlChangeListeners = [],
urlChangeInit = false;
function cacheStateAndFireUrlChange() {
pendingLocation = null;
fireStateOrUrlChange();
}
// This variable should be used *only* inside the cacheState function.
var lastCachedState = null;
function cacheState() {
// This should be the only place in $browser where `history.state` is read.
cachedState = getCurrentState();
cachedState = isUndefined(cachedState) ? null : cachedState;
// Prevent callbacks fo fire twice if both hashchange & popstate were fired.
if (equals(cachedState, lastCachedState)) {
cachedState = lastCachedState;
}
lastCachedState = cachedState;
lastHistoryState = cachedState;
}
function fireStateOrUrlChange() {
var prevLastHistoryState = lastHistoryState;
cacheState();
if (lastBrowserUrl === self.url() && prevLastHistoryState === cachedState) {
return;
}
lastBrowserUrl = self.url();
lastHistoryState = cachedState;
forEach(urlChangeListeners, function(listener) {
listener(self.url(), cachedState);
});
}
/**
* @name $browser#onUrlChange
*
* @description
* Register callback function that will be called, when url changes.
*
* It's only called when the url is changed from outside of AngularJS:
* - user types different url into address bar
* - user clicks on history (forward/back) button
* - user clicks on a link
*
* It's not called when url is changed by $browser.url() method
*
* The listener gets called with new url as parameter.
*
* NOTE: this api is intended for use only by the $location service. Please use the
* {@link ng.$location $location service} to monitor url changes in AngularJS 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.onUrlChange = function(callback) {
// TODO(vojta): refactor to use node's syntax for events
if (!urlChangeInit) {
// We listen on both (hashchange/popstate) when available, as some browsers don't
// fire popstate when user changes the address bar and don't fire hashchange when url
// changed by push/replaceState
// html5 history api - popstate event
if ($sniffer.history) jqLite(window).on('popstate', cacheStateAndFireUrlChange);
// hashchange event
jqLite(window).on('hashchange', cacheStateAndFireUrlChange);
urlChangeInit = true;
}
urlChangeListeners.push(callback);
return callback;
};
/**
* @private
* Remove popstate and hashchange handler from window.
*
* NOTE: this api is intended for use only by $rootScope.
*/
self.$$applicationDestroyed = function() {
jqLite(window).off('hashchange popstate', cacheStateAndFireUrlChange);
};
/**
* Checks whether the url has changed outside of AngularJS.
* Needs to be exported to be able to check for changes that have been done in sync,
* as hashchange/popstate events fire in async.
*/
self.$$checkUrlChange = fireStateOrUrlChange;
//////////////////////////////////////////////////////////////
// Misc API
//////////////////////////////////////////////////////////////
/**
* @name $browser#baseHref
*
* @description
* Returns current <base href>
* (always relative - without domain)
*
* @returns {string} The current base href
*/
self.baseHref = function() {
var href = baseElement.attr('href');
return href ? href.replace(/^(https?:)?\/\/[^/]*/, '') : '';
};
/**
* @name $browser#defer
* @param {function()} fn A function, who's execution should be deferred.
* @param {number=} [delay=0] Number of milliseconds to defer the function execution.
* @param {string=} [taskType=DEFAULT_TASK_TYPE] The type of task that is deferred.
* @returns {*} DeferId that can be used to cancel the task via `$browser.defer.cancel()`.
*
* @description
* Executes a fn asynchronously via `setTimeout(fn, delay)`.
*
* Unlike when calling `setTimeout` directly, in test this function is mocked and instead of using
* `setTimeout` in tests, the fns are queued in an array, which can be programmatically flushed
* via `$browser.defer.flush()`.
*
*/
self.defer = function(fn, delay, taskType) {
var timeoutId;
delay = delay || 0;
taskType = taskType || taskTracker.DEFAULT_TASK_TYPE;
taskTracker.incTaskCount(taskType);
timeoutId = setTimeout(function() {
delete pendingDeferIds[timeoutId];
taskTracker.completeTask(fn, taskType);
}, delay);
pendingDeferIds[timeoutId] = taskType;
return timeoutId;
};
/**
* @name $browser#defer.cancel
*
* @description
* Cancels a deferred task identified with `deferId`.
*
* @param {*} deferId Token returned by the `$browser.defer` function.
* @returns {boolean} Returns `true` if the task hasn't executed yet and was successfully
* canceled.
*/
self.defer.cancel = function(deferId) {
if (pendingDeferIds.hasOwnProperty(deferId)) {
var taskType = pendingDeferIds[deferId];
delete pendingDeferIds[deferId];
clearTimeout(deferId);
taskTracker.completeTask(noop, taskType);
return true;
}
return false;
};
}
/** @this */
function $BrowserProvider() {
this.$get = ['$window', '$log', '$sniffer', '$document', '$$taskTrackerFactory',
function($window, $log, $sniffer, $document, $$taskTrackerFactory) {
return new Browser($window, $document, $log, $sniffer, $$taskTrackerFactory);
}];
}