boomerangjs
Version:
boomerang always comes back, except when it hits something
549 lines (471 loc) • 17.7 kB
JavaScript
/**
* The History plugin allows you to automatically monitor Single Page
* App (SPA) navigations that change their routes via the
* [`window.history`](https://developer.mozilla.org/en-US/docs/Web/API/Window/history)
* object.
*
* The History plugin can be used for any SPA (eg. Angular, Backbone, Ember, React, Vue, etc.)
* and replaces the deprecated Angular, Backbone and Ember plugins.
*
* **Note**: This plugin requires the {@link BOOMR.plugins.AutoXHR} and
* {@link BOOMR.plugins.SPA} plugins to be loaded first (in that order).
*
* For details on how Boomerang Single Page App instrumentation works, see the
* {@link BOOMR.plugins.SPA} documentation.
*
* For information on how to include this plugin, see the {@tutorial building} tutorial.
*
* ## Compatibility
*
* This plugin should work with any Single Page App, by instrumenting the
* [`window.history`](https://developer.mozilla.org/en-US/docs/Web/API/Window/history)
* object to monitor for route changes.
*
* The SPA app needs to change the history state or hash before doing the work required to
* change the route (eg. XHRs, DOM node changes). With frameworks where the history events
* happen after the route change has completed (e.g. Ember.js 1.x), we can configure the
* plugin with `monitorHistory: false` and call `BOOMR.plugins.SPA.route_change()` manually
* when the route change begins.
*
* ## Beacon Parameters
*
* This plugin does not add any additional beacon parameters beyond the
* {@link BOOMR.plugins.SPA} plugin.
*
* ## Usage
*
* Include the {@link BOOMR.plugins.AutoXHR}, {@link BOOMR.plugins.SPA}
* and {@link BOOMR.plugins.History} plugins. See {@tutorial building} for details.
*
* @class BOOMR.plugins.History
*/
(function() {
var impl = {
// if monitorHistory is false then the History object will not be hooked
monitorHistory: true,
// whether or not the plugin is enabled
enabled: true,
// whether or not the hook has been enabled
hooked: false,
// will store the setTimeout id when set
routeChangeInProgress: false,
// whether or not to disable SPA hard beacons
disableHardNav: false,
// route change filter callback function
routeFilter: undefined,
// route change wait filter callback function
routeChangeWaitFilter: undefined,
// whether to apply wait filter on hard navs
routeChangeWaitFilterHardNavs: false,
// whether or not to hook history.replaceState
monitorReplaceState: true,
// helper anchor object used to cleanup urls
a: undefined,
// browser onload happened before our setup
browserOnloadBeforeSetup: false,
// list of plugins that used to contain framework-specific code, that this plugin now replaces
DEPRECATED_PLUGINS: ["Angular", "Backbone", "Ember"],
/**
* Clears routeChangeInProgress flag
*/
resetRouteChangeInProgress: function(edata) {
// Three types of beacons can go out before the Page Load beacon: Early Beacon, Custom Metric and Custom Timer.
// For those beacon types, we want to keep the state unchanged.
if (edata &&
(
(typeof edata.early !== "undefined") ||
(edata["http.initiator"] && edata["http.initiator"].indexOf("api_custom_") === 0)
)) {
return;
}
// XHR beacons might go out during a SPA Navigation if alwaysSendXhr:true. In that case, don't reset the current
// route change.
if (edata && edata["http.initiator"] === "xhr") {
return;
}
debugLog("resetting routeChangeInProgress");
if (impl.routeChangeInProgress) {
clearTimeout(impl.routeChangeInProgress);
}
impl.routeChangeInProgress = false;
},
/**
* Sets routeChangeInProgress flag and sets up timer to clear it later
*/
setRouteChangeInProgress: function() {
if (impl.routeChangeInProgress) {
clearTimeout(impl.routeChangeInProgress);
}
// reset our routeChangeInProgress flag as soon as the browser is free.
// Current browser behavior favors sending internal events over calling
// timeout callbacks. If for example the back button is clicked and a replaceState
// is called then the popstate event should be triggered to extend this timeout before
// the callback is called.
impl.routeChangeInProgress = setTimeout(impl.resetRouteChangeInProgress, 50);
},
/**
* Called on route change
*/
routeChange: function(event) {
if (!impl.enabled) {
debugLog("Not enabled - we've missed a routeChange");
impl.resetRouteChangeInProgress();
}
else {
// don't track the SPA route change until the onload (page_ready)
// has fired
if (impl.disableHardNav && !BOOMR.onloadFired()) {
debugLog("disableHardNav and not page_ready, not triggering");
return;
}
if (!impl.routeChangeInProgress) {
debugLog("routeChange triggered, sending route_change() event");
if (event.toUrl) {
impl.a.href = event.toUrl;
event.toUrl = impl.a.href;
}
BOOMR.plugins.SPA.route_change(null, [event.type, event.fromUrl, event.toUrl]);
}
else {
debugLog("routeChangeInProgress, not triggering");
}
}
}
};
BOOMR = window.BOOMR || {};
BOOMR.plugins = BOOMR.plugins || {};
// Checking for Plugins required and if already integrated
if (BOOMR.plugins.History ||
typeof BOOMR.plugins.SPA === "undefined" ||
typeof BOOMR.plugins.AutoXHR === "undefined") {
return;
}
// check that window and document available
if (!BOOMR.window || !BOOMR.window.document) {
return;
}
// register as a SPA plugin
BOOMR.plugins.SPA.register("History");
impl.a = BOOMR.window.document.createElement("A");
/* BEGIN_DEBUG */
/**
* Debug logging for this instance
*
* @param {string} msg Message
*/
function debugLog(msg) {
BOOMR.debug(msg, "History");
};
/* END_DEBUG */
/**
* Hook into `window.history` Object
*
* This function will override the following functions if available:
* - pushState
* - replaceState
* - go
* - back
* - forward
* And listen to event:
* - hashchange
* - popstate
*/
function setup() {
var history = BOOMR.window.history;
//
// History API overrides
//
if (typeof history.pushState === "function") {
history.pushState = (function(_pushState) {
return function(state, title, url) {
debugLog("pushState, title: " + title + " url: " + url);
impl.routeChange({
type: "pushState",
fromUrl: BOOMR.window.document.URL,
toUrl: url
});
return _pushState.apply(this, arguments);
};
})(history.pushState);
}
if (impl.monitorReplaceState && typeof history.replaceState === "function") {
history.replaceState = (function(_replaceState) {
return function(state, title, url) {
var fromUrl = BOOMR.window.document.URL,
toUrl = fromUrl;
// url is an optional param
if (arguments.length >= 3 && typeof url !== "undefined" && url !== null) {
// normalize url
impl.a.href = url;
toUrl = impl.a.href;
}
// only issue route change if a nav is not in progress or the URL is changing
if (!BOOMR.plugins.SPA.isSpaNavInProgress() || toUrl !== fromUrl) {
debugLog("replaceState, title: " + title + " url: " + url);
impl.routeChange({
type: "pushState",
fromUrl: BOOMR.window.document.URL, toUrl: url
});
}
else {
/* BEGIN_DEBUG */
debugLog(
"replaceState ignored (no URL change and a SPA nav is in progress), title: " +
title +
" url: " +
url);
/* END_DEBUG */
}
return _replaceState.apply(this, arguments);
};
})(history.replaceState);
}
// we instrument go, back and forward because they are called earlier than the
// popstate event which gives AutoXHR a chance to setup the MO
if (typeof history.go === "function") {
history.go = (function(_go) {
return function(index) {
debugLog("go");
// spa_init url will be the url before `go` runs .. for routefilter also
impl.routeChange({
type: "go",
fromUrl: BOOMR.window.document.URL
});
return _go.apply(this, arguments);
};
})(history.go);
}
if (typeof history.back === "function") {
history.back = (function(_back) {
return function() {
debugLog("back");
// spa_init url will be the url before `back` runs
impl.routeChange({
type: "back",
fromUrl: BOOMR.window.document.URL
});
return _back.apply(this, arguments);
};
})(history.back);
}
if (typeof history.forward === "function") {
history.forward = (function(_forward) {
return function() {
debugLog("forward");
// spa_init url will be the url before `forward` runs
impl.routeChange({
type: "forward",
fromUrl: BOOMR.window.document.URL
});
return _forward.apply(this, arguments);
};
})(history.forward);
}
// listen for hash changes.
// hashchange events may be available even if the browser does not support the History API
BOOMR.window.addEventListener("hashchange", function(event) {
var url = (event || {}).newURL;
debugLog("hashchange " + url);
impl.routeChange({
type: "hashchange",
toUrl: url
});
});
/**
* Add event listener for `popstate`
*/
function aelPopstate() {
// popstate events may be available even if the browser does not support the History API
BOOMR.window.addEventListener("popstate", function(event) {
debugLog("popstate");
impl.routeChange({
type: "popstate",
toUrl: BOOMR.window.document.URL
});
});
}
// add listener for popstate after page load has occured so that we don't receive an unwanted popstate
// event at onload
if (BOOMR.hasBrowserOnloadFired()) {
aelPopstate();
}
else {
// the event listener will be registered early enough to get an unwanted event if we don't use setTimeout
BOOMR.window.addEventListener("load", function() {
setTimeout(aelPopstate, 0);
});
}
// listen for a beacon
BOOMR.subscribe("beacon", impl.resetRouteChangeInProgress);
// listen for spa cancellations
BOOMR.subscribe("spa_cancel", impl.resetRouteChangeInProgress);
// listen for spa inits. We're adding this to catch the event sent by the SPA plugin
BOOMR.subscribe("spa_init", impl.setRouteChangeInProgress);
// if browser onload event has happened, assume we missed the route change
impl.browserOnloadBeforeSetup = BOOMR.hasBrowserOnloadFired();
return true;
};
//
// Exports
//
BOOMR.plugins.History = {
/**
* This plugin is always complete (ready to send a beacon)
*
* @returns {boolean} `true`
* @memberof BOOMR.plugins.History
*/
is_complete: function() {
return true;
},
/**
* Hooks Boomerang into the History events.
*
* @param {object} history Deprecated
* @param {boolean} [hadRouteChange] Deprecated
* event prior to this `hook()` call
* @param {object} [options] Optional options. Can contain `routeFilter` and/or `routeChangeWaitFilter`
*
* @returns {@link BOOMR.plugins.History} The History plugin for chaining
* @memberof BOOMR.plugins.History
*/
hook: function(history, hadRouteChange, options) {
try {
debugLog("hook: " + JSON.stringify(options));
}
catch (e) {
debugLog("hook: can't stringify options");
}
options = options || {};
options.disableHardNav = impl.disableHardNav;
if (impl.routeFilter) {
options.routeFilter = impl.routeFilter;
}
if (impl.routeChangeWaitFilter) {
options.routeChangeWaitFilter = impl.routeChangeWaitFilter;
}
if (impl.routeChangeWaitFilterHardNavs) {
options.routeChangeWaitFilterHardNavs = impl.routeChangeWaitFilterHardNavs;
}
if (!impl.hooked && impl.monitorHistory) {
setup();
}
// allow to call again in case options changed
hadRouteChange = impl.browserOnloadBeforeSetup;
BOOMR.plugins.SPA.hook(hadRouteChange, options);
if (!impl.hooked && !impl.browserOnloadBeforeSetup && (!impl.disableHardNav || BOOMR.onloadFired())) {
// fire our route change asap so that we can listen for mutatations, etc
BOOMR.plugins.SPA.route_change();
impl.setRouteChangeInProgress();
}
impl.hooked = true;
return this;
},
/**
* Initializes the plugin.
*
* @param {object} config Configuration
* @param {boolean} [config.History.auto] Whether or not to automatically
* instrument the `window.history` object.
* If set to `false`, the React snippet should be used.
* @param {boolean} [config.History.disableHardNav] Whether or not to disable SPA hard beacons
* @param {function} [config.History.routeFilter] Route change filter callback function
* @param {function} [config.History.routeChangeWaitFilter] Route change wait filter callback function.
* This is called on each route change, and if returns true, Boomerang will wait for
* a `BOOMR.plugins.SPA.wait_complete()` call before marking a navigation complete. By default, this only
* applies to SPA Soft navigations.
* @param {boolean} [config.History.routeChangeWaitFilterHardNavs] Whether to apply wait filter on hard navs.
* If set to `true`, the `routeChangeWaitFilter` function will apply to SPA hard
* navigations in addition to soft navigations.
* @param {boolean} [config.History.monitorReplaceState] Whether or not to hook History.replaceState
*
* @returns {@link BOOMR.plugins.History} The History plugin for chaining
* @example <caption>Basic</caption>
* BOOMR.init({
* History: {
* enabled: true
* }
* });
*
* @example <caption>With routeChangeWaitFilter and routeChangeWaitFilterHardNavs</caption>
* BOOMR.init({
* History: {
* enabled: true,
* routeChangeWaitFilter: function() {
* if (window.location.href.indexOf("route-that-needs-to-wait/") !== -1) {
* // You're telling Boomerang that in addition to the standard SPA heuristics,
* // Boomerang should additionally wait for your application to call this API:
* //
* // BOOMR.plugins.SPA.wait_complete();
* //
* return true;
* }
*
* // You're telling Boomerang that the standard SPA heuristics will be applied to this
* // route, no custom endpoint is needed. BOOMR.plugins.SPA.wait_complete()
* // should not be called.
* return false;
* },
* routeChangeWaitFilterHardNavs: true
* }
* });
*
* @memberof BOOMR.plugins.History
*/
init: function(config) {
BOOMR.utils.pluginConfig(impl, config, "History",
["enabled", "monitorHistory", "disableHardNav",
"routeFilter", "routeChangeWaitFilter", "routeChangeWaitFilterHardNavs",
"monitorReplaceState"]);
if (impl.enabled) {
this.hook();
}
return this;
},
/**
* Disables the History plugin
*
* @returns {@link BOOMR.plugins.History} The History plugin for chaining
* @memberof BOOMR.plugins.History
*/
disable: function() {
impl.enabled = false;
return this;
},
/**
* Enables the History plugin
*
* @returns {@link BOOMR.plugins.History} The History plugin for chaining
* @memberof BOOMR.plugins.History
*/
enable: function() {
impl.enabled = true;
return this;
}
};
/* eslint-disable no-loop-func */
// create backwards compatible plugins that now use History
for (var i = 0; i < impl.DEPRECATED_PLUGINS.length; i++) {
var plugin_name = impl.DEPRECATED_PLUGINS[i];
BOOMR.plugins[plugin_name] = BOOMR.plugins.History;
BOOMR.plugins[plugin_name] = {
init: (function(_plugin_name) {
return function(config) {
BOOMR.utils.pluginConfig(impl, config, _plugin_name, ["enabled"]);
if (impl.enabled) {
debugLog("Deprecated: Initialized from " + _plugin_name + " config");
BOOMR.plugins.History.hook(undefined, undefined, {});
}
return BOOMR.plugins[_plugin_name];
};
})(plugin_name),
enable: BOOMR.plugins.History.enable,
// we don't want these plugins to have a `disable` function otherwise they will disable the History plugin
hook: BOOMR.plugins.History.hook,
is_complete: BOOMR.plugins.History.is_complete
};
// register as a SPA plugin (need this because the SPA plugin will look at each plugin config)
BOOMR.plugins.SPA.register(plugin_name);
}
/* eslint-enable no-loop-func */
}());