diff --git a/plugins/angular.js b/plugins/angular.js index f5e32f3e8..d4fb65ef4 100644 --- a/plugins/angular.js +++ b/plugins/angular.js @@ -37,7 +37,9 @@ (function() { var hooked = false, enabled = true, - hadMissedRouteChange = false; + hadMissedRouteChange = false, + lastRouteChangeTime = 0, + locationChangeTrigger = false; if (BOOMR.plugins.Angular || typeof BOOMR.plugins.SPA === "undefined") { return; @@ -76,6 +78,36 @@ log("Startup"); + /** + * Fires the SPA route_change event. + * + * If it's been over 50ms since the last route change, fire a new + * route change event. Several Angular events may call this function that + * trigger off each other (e.g. $routeChangeStart from $locationChangeStart), + * so this combines them into a single route change. + * + * We also want $routeChangeStart/$stateChangeStart to trigger the route_change() + * if possible because those have arguments that we'll pass to the routeFilter + * if specified. + * + * To do this, $locationChangeStart sets a timeout before calling this + * that will be cleared by the $routeChangeStart/$stateChangeStart. + */ + function fireRouteChange() { + var now = BOOMR.now(); + + if (now - lastRouteChangeTime > 50) { + BOOMR.plugins.SPA.route_change.call(null, null, arguments); + } + + lastRouteChangeTime = now; + + // clear any $locationChangeStart triggers since we've handled it + // either via $routeChangeStart or $stateChangeStart + clearTimeout(locationChangeTrigger); + locationChangeTrigger = false; + } + // // Traditional Angular Router events // @@ -91,7 +123,7 @@ log("$routeChangeStart: " + (next ? next.templateUrl : "")); - BOOMR.plugins.SPA.route_change(event, next, current); + fireRouteChange(event, next, current); }); // Listen for $locationChangeStart to know the new URL when the route changes @@ -104,6 +136,12 @@ BOOMR.fireEvent("spa_init", [BOOMR.plugins.SPA.current_spa_nav(), newState]); BOOMR.plugins.SPA.last_location(newState); + + // Fire a route change (on a short delay) after this callback in case + // $routeChangeStart never fires. We'd prefer to use $routeChangeStart's + // arguments (next, current) for any routeFilter, so use a setTimeout + // that may be cancelled by the $routeChangeStart/$stateChangeStart event. + locationChangeTrigger = setTimeout(fireRouteChange, 0); }); // @@ -117,7 +155,7 @@ log("$stateChangeStart: " + toState); - BOOMR.plugins.SPA.route_change(event, toState, toParams, fromState, fromParams); + fireRouteChange(event, toState, toParams, fromState, fromParams); }); $rootScope.$on("$stateChangeSuccess", function(event, toState, toParams, fromState, fromParams) { diff --git a/plugins/spa.js b/plugins/spa.js index a3a69829f..71e673ac2 100644 --- a/plugins/spa.js +++ b/plugins/spa.js @@ -177,12 +177,13 @@ * Called by a framework when a route change has happened * * @param {function} onComplete Called on completion + * @param {object[]} routeFilterArgs Route Filter arguments */ - route_change: function(onComplete) { + route_change: function(onComplete, routeFilterArgs) { // if we have a routeFilter, see if they want to track this route if (routeFilter) { try { - if (!routeFilter.apply(null, arguments)) { + if (!routeFilter.apply(null, routeFilterArgs)) { return; } } diff --git a/tests/page-templates/05-angular/108-location-change-only.html b/tests/page-templates/05-angular/108-location-change-only.html new file mode 100644 index 000000000..378861f16 --- /dev/null +++ b/tests/page-templates/05-angular/108-location-change-only.html @@ -0,0 +1,34 @@ +<%= header %> +<%= boomerangScript %> + + + + + + +
+
+
+
+ + +<%= footer %> diff --git a/tests/page-templates/05-angular/108-location-change-only.js b/tests/page-templates/05-angular/108-location-change-only.js new file mode 100644 index 000000000..62079e1a6 --- /dev/null +++ b/tests/page-templates/05-angular/108-location-change-only.js @@ -0,0 +1,138 @@ +/*eslint-env mocha*/ +describe("e2e/05-angular/108-location-change-only", function() { + var tf = BOOMR.plugins.TestFramework; + var t = BOOMR_test; + + var pathName = window.location.pathname; + + it("Should pass basic beacon validation", function(done) { + t.validateBeaconWasSent(done); + }); + + it("Should have sent three beacons", function() { + assert.equal(tf.beacons.length, 3); + }); + + it("Should have sent the first beacon as http.initiator = spa_hard", function() { + assert.equal(tf.beacons[0]["http.initiator"], "spa_hard"); + }); + + it("Should have sent all subsequent beacons as http.initiator = spa", function() { + for (var i = 1; i < 2; i++) { + assert.equal(tf.beacons[i]["http.initiator"], "spa"); + } + }); + + it("Should have sent all subsequent beacons have rt.nstart = navigationTiming (if NavigationTiming is supported)", function() { + if (typeof BOOMR.plugins.RT.navigationStart() !== "undefined") { + for (var i = 1; i < 2; i++) { + assert.equal(tf.beacons[i]["rt.nstart"], BOOMR.plugins.RT.navigationStart()); + } + } + }); + + it("Should not have Boomerang timings on SPA Soft beacons", function() { + for (var i = 1; i < 2; i++) { + if (tf.beacons[i].t_other) { + assert.equal(tf.beacons[i].t_other.indexOf("boomr_fb"), -1, "should not have boomr_fb"); + assert.equal(tf.beacons[i].t_other.indexOf("boomr_ld"), -1, "should not have boomr_ld"); + assert.equal(tf.beacons[i].t_other.indexOf("boomr_lat"), -1, "should not have boomr_lat"); + assert.equal(tf.beacons[i].t_other.indexOf("boomerang"), -1, "should not have boomerang"); + } + + // Boomerang and config timing parameters + assert.isUndefined(tf.beacons[i]["rt.bmr"]); + assert.isUndefined(tf.beacons[i]["rt.cnf"]); + } + }); + + // + // Beacon 1 + // + it("Should have sent the first beacon for " + pathName, function() { + var b = tf.beacons[0]; + assert.isTrue(b.u.indexOf(pathName) !== -1); + }); + + it("Should have a load time (if MutationObserver and NavigationTiming are supported)", function() { + if (window.MutationObserver && typeof BOOMR.plugins.RT.navigationStart() !== "undefined") { + var b = tf.beacons[0]; + assert.isDefined(b.t_done); + } + }); + + it("Should not have a load time (if MutationObserver is supported but NavigationTiming is not)", function() { + if (window.MutationObserver && typeof BOOMR.plugins.RT.navigationStart() === "undefined") { + var b = tf.beacons[0]; + assert.equal(b.t_done, undefined); + } + }); + + it("Should take as long as the XHRs (if MutationObserver is not supported but NavigationTiming is)", function() { + if (typeof window.MutationObserver === "undefined" && typeof BOOMR.plugins.RT.navigationStart() !== "undefined") { + t.validateBeaconWasSentAfter(0, "widgets.json", 500, 0, 30000, false); + } + }); + + it("Shouldn't have a load time (if MutationObserver and NavigationTiming are not supported)", function() { + if (typeof window.MutationObserver === "undefined" && typeof BOOMR.plugins.RT.navigationStart() === "undefined") { + var b = tf.beacons[0]; + assert.equal(b.t_done, undefined); + assert.equal(b["rt.start"], "none"); + } + }); + + it("Should have a t_resp of the root page (if MutationObserver and NavigationTiming are supported)", function() { + if (window.MutationObserver && typeof BOOMR.plugins.RT.navigationStart() !== "undefined") { + var pt = window.performance.timing; + var b = tf.beacons[0]; + assert.equal(b.t_resp, pt.responseStart - pt.fetchStart); + } + }); + + it("Should have a t_page of total - t_resp (if MutationObserver and NavigationTiming are supported)", function() { + if (window.MutationObserver && typeof BOOMR.plugins.RT.navigationStart() !== "undefined") { + var pt = window.performance.timing; + var b = tf.beacons[0]; + assert.equal(b.t_page, b.t_done - b.t_resp); + } + }); + + // + // Beacon 2 + // + it("Should have sent the second beacon for /nothing", function() { + var b = tf.beacons[1]; + assert.isTrue(b.u.indexOf("/nothing") !== -1); + }); + + it("Should have sent the second beacon with a timestamp of ~0 seconds (if MutationObserver is supported)", function() { + if (window.MutationObserver) { + var b = tf.beacons[1]; + assert.closeTo(b.t_done, 0, 50); + } + }); + + // + // Beacon 3 + // + it("Should have sent the third beacon for " + pathName, function() { + var b = tf.beacons[2]; + assert.isTrue(b.u.indexOf(pathName) !== -1); + }); + + it("Should have sent the third with a timestamp of at around 0 seconds (if MutationObserver is supported)", function() { + if (window.MutationObserver) { + var b = tf.beacons[2]; + assert.closeTo(b.t_done, 0, 50); + } + }); + + it("Should have set the same Page ID (pid) on all beacons", function() { + var pid = tf.beacons[0].pid; + for (var i = 0; i <= 2; i++) { + var b = tf.beacons[i]; + assert.equal(b.pid, pid); + } + }); +}); diff --git a/tests/page-templates/05-angular/support/app.js b/tests/page-templates/05-angular/support/app.js index a97a924ce..ae8a15019 100644 --- a/tests/page-templates/05-angular/support/app.js +++ b/tests/page-templates/05-angular/support/app.js @@ -1,5 +1,10 @@ /*global angular*/ -angular.module("app", ["ngResource", "ngRoute"]) +var modules = ["ngResource"]; +if (!window.angular_no_route) { + modules.push("ngRoute"); +} + +var app = angular.module("app", modules) .factory("Widgets", ["$resource", function($resource) { // NOTE: Using absolute urls instead of relative URLs otherwise IE11 has problems // resolving them in html5Mode @@ -57,12 +62,14 @@ angular.module("app", ["ngResource", "ngRoute"]) } }); }]) - - .config(["$routeProvider", "$locationProvider", function($routeProvider, $locationProvider) { + .config(["$locationProvider", function($locationProvider) { if (typeof window.angular_html5_mode !== "undefined" && window.angular_html5_mode) { $locationProvider.html5Mode(true); } + }]); +if (!window.angular_no_route) { + app.config(["$routeProvider", function($routeProvider) { // NOTE: Using absolute urls instead of relative URLs otherwise IE11 has problems // resolving them in html5Mode $routeProvider. @@ -88,63 +95,64 @@ angular.module("app", ["ngResource", "ngRoute"]) templateUrl: "/pages/05-angular/support/home.html", controller: "mainCtrl" }); - }]) + }]); +} - .run(["$rootScope", "$location", "$timeout", function($rootScope, $location, $timeout) { - var hadRouteChange = false; +app.run(["$rootScope", "$location", "$timeout", function($rootScope, $location, $timeout) { + var hadRouteChange = false; - $rootScope.$on("$routeChangeStart", function() { - hadRouteChange = true; - }); + $rootScope.$on("$routeChangeStart", function() { + hadRouteChange = true; + }); - var hookOptions = {}; - if (window.angular_route_wait) { - hookOptions.routeChangeWaitFilter = window.angular_route_wait; - } + var hookOptions = {}; + if (window.angular_route_wait) { + hookOptions.routeChangeWaitFilter = window.angular_route_wait; + } - if (window.angular_route_filter) { - hookOptions.routeFilter = window.angular_route_filter; - } + if (window.angular_route_filter) { + hookOptions.routeFilter = window.angular_route_filter; + } - function hookAngularBoomerang() { - if (window.BOOMR && BOOMR.version) { - if (BOOMR.plugins && BOOMR.plugins.Angular) { - BOOMR.plugins.Angular.hook($rootScope, hadRouteChange, hookOptions); - } - return true; + function hookAngularBoomerang() { + if (window.BOOMR && BOOMR.version) { + if (BOOMR.plugins && BOOMR.plugins.Angular) { + BOOMR.plugins.Angular.hook($rootScope, hadRouteChange, hookOptions); } + return true; } + } - if (!hookAngularBoomerang()) { - if (document.addEventListener) { - document.addEventListener("onBoomerangLoaded", hookAngularBoomerang); - } - else if (document.attachEvent) { - document.attachEvent("onpropertychange", function(e) { - e = e || window.event; - if (e && e.propertyName === "onBoomerangLoaded") { - hookAngularBoomerang(); - } - }); - } + if (!hookAngularBoomerang()) { + if (document.addEventListener) { + document.addEventListener("onBoomerangLoaded", hookAngularBoomerang); } + else if (document.attachEvent) { + document.attachEvent("onpropertychange", function(e) { + e = e || window.event; + if (e && e.propertyName === "onBoomerangLoaded") { + hookAngularBoomerang(); + } + }); + } + } - if (typeof window.angular_nav_routes !== "undefined" && - Object.prototype.toString.call(window.angular_nav_routes) === "[object Array]") { - BOOMR.subscribe("onbeacon", function(beacon) { - // only continue for SPA beacons - if (!BOOMR.utils.inArray(beacon["http.initiator"], BOOMR.constants.BEACON_TYPE_SPAS)) { - return; - } + if (typeof window.angular_nav_routes !== "undefined" && + Object.prototype.toString.call(window.angular_nav_routes) === "[object Array]") { + BOOMR.subscribe("onbeacon", function(beacon) { + // only continue for SPA beacons + if (!BOOMR.utils.inArray(beacon["http.initiator"], BOOMR.constants.BEACON_TYPE_SPAS)) { + return; + } - if (window.angular_nav_routes.length > 0) { - var nextRoute = window.angular_nav_routes.shift(); + if (window.angular_nav_routes.length > 0) { + var nextRoute = window.angular_nav_routes.shift(); - $timeout(function() { - $location.url(nextRoute); - }, 100); - } - }); - } - }]); + $timeout(function() { + $location.url(nextRoute); + }, 100); + } + }); + } +}]);