Introducing: Darius Capizzi
Role: Developer
Time at roboboogie: 2 years and counting…
I’ve been part of the roboboogie team that optimizes web experiences through multivariate and A/B experimentation for just over two years. In that time, I’ve had the opportunity to solve for and build highly integrated experiments using a variety of testing platforms: reworking a checkout funnel top-to-bottom, dynamically altering content based on unique events, and utilizing complex targeting algorithms to power personalized experiences. At roboboogie, we work with an ever-evolving array of clients spanning a number of different industries with unique challenges, technologies, and goals. Part of my role is to identify wholistic test development solutions that maximize efficiency and effectiveness of test builds based upon the plugins, platforms, frameworks, and tools the client has available. One of the biggest hurdles I have encountered is when developing tests interacting with AngularJS.
Angular is Javascript framework, allowing for quicker, more immersive user experiences. It does this by loading content asynchronously. The result is a snappy, clean experience for the user. However, it creates challenges when attempting to load outside technologies for testing, like the script loaded through Optimizely to trigger experiments, track goals, and load content.
Ready for the good news? I’m here to share what I’ve learned and what’s worked for us at roboboogie!
The Scenario:
You’ve built a rockin’ A/B Test for a site built with Angular using Optimizely. Credit where credit is due: nice job.
You spin up the experiment to test it and it works locally: double nice job. But when you test it live, oh jeez…. Something is wrong. For some reason, AngularJS hasn’t loaded that component when your Optimizely code tried to trigger the change. After triple-checking your test code, everything checks out within Optimizely, yet the experiment isn’t triggering. Awww, of course. Angular loads content asynchronously and will probably finish updating elements after Optimizely has finished its setup.
Ready to give up? Don’t! People do this stuff. Let’s unpack some solutions to address the issue.
The Solution(s):
Below I’ve outlined what I have found to be the best methods to work through the issue. You will have to customize and troubleshoot based upon your stack, but the following should get you what you need to run your tests successfully.
(Warning: the following content is about to get very techy. So hold on to your coder pants.)
Solution 1: Using Conditional Activation
This boils down to triggering your experiment within your Angular codebase. If you are the owner and operator of the web property and have access to make changes within the Angular implementation, this is the ideal solution. It involves building each variation into your codebase and conditionally activating the experiment and its variations. However, if you do not have this luxury (working without direct contact with development team, or are servicing a client), the requirement for another team to deploy code every time you want to run an experiment usually creates too much overhead for the in-house team..
For further reading, check out Optimizely’s documentation for Classic and X.
Solution 2. Wait for AngularJS by Polling for Window.angular
If you are working through a snippet alone, without access to the codebase, and need an alternate fix polling for Window.angular can be a good solution. Here is some code to get you started polling:
var tooManyLoops = 0, doOnce = true, viewModel = "", maxLoops = 3000, timeoutDelay = 300;
var observeAngular = function(inElement, callback) {
var elementToCheck = $(inElement);
if (
!window.angular ||
!window.angular.element(elementToCheck) ||
!window.angular.element(elementToCheck).scope() ||
!window.angular.element(elementToCheck).scope().vm ||
!window.angular.element(elementToCheck).scope().vm.desiredFeature
) {
tooManyLoops++;
if (tooManyLoops > maxLoops) {
return;
}
setTimeout(function(){observeAngular(inElement, callback)}, timeoutDelay);
} else if (doOnce) {
doOnce = false;
viewModel = window.angular.element(elementToCheck).scope().vm;
callback();
} else {
return;
}
};
observeAngular('.some-angular-element', yourDomManipulation);
This is a recursive solution where we poll for an element and its associated scope. Angular binds data to elements, so to get the data, we grab the element. Once we have the scope returned, then we know DOM is ready for manipulation. The scope associated with an element might also provide you a slew of relevant information, such as out of stock status or original vs. reduced pricing. It should be as simple as targeting the troubled/refreshing elements, or their parent elements. Sometimes it can be tricky to find $('.some-angular-element')
. If you encounter this, look around the DOM for ng- attributes, like ng-repeat or ng-show, and check those elements.
Tip: If running window.angular.element($('.some-angular-element')).scope()
returns undefined, then debugInfoEnabled is probably set to false. Run angular.reloadWithDebugInfo();
in the console, then try to grab scope again and you should see the scope being returned. You may have to ask the client to set debugInfoEnabled to true for some functionality.
vm
is a commonly used object to reference the element’s controller and in this case stands for “View’s Model.” It is described in the controller as syntax, so it’s usually there. If not, you might have to dig around in the scope for another object to poll for or an alias to vm
. Use window.angular.element($('.some-angular-element')).scope()
in the console to check for vm
, and try checking its .$parent
if the vm
object is not present. Finally, in the code above, “desiredFeature” can be any property. You can expect things like an add-to-cart method on an add-to-cart button or product comparison tools on product pages.
So now you have the initial DOM manipulation working, and it looks super great. For pages that do not reload elements based upon user interaction, you can give props to your designers and you’re done. Otherwise, if you do have refreshing elements and these $('.some-angular-element')
‘s refresh on click of a button, they will strip your styles from the DOM and reset the page to some hybrid of the original and your current test build. You probably should have caught this in initial development, but it’s never too late! It’s time to…
Solution 3: Observe the Element with a Mutation Observer
The disadvantage to the setInterval solution is recurring flashing, since the setTimeout will (likely) not run immediately after the element was added, like a Mutation Observer that is listening for changes on specific elements will. Before testing, check this caniuse, which namely excludes Opera Mini and IE (<=10). Don’t forget to adjust your audience settings accordingly.
Here is a simple example of a mutation observer:
var observeDOM = function(elementContainer, elementSelector){
var MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
var options = { childList:true, subtree:true };
var observer = new MutationObserver(function(mutations){
console.log(mutations) //here to get a better picture of how they come in
for (var i = 0; i < mutations.length; i++) { if( mutations[i].addedNodes.length > 0) {
if ($(mutations[i].addedNodes[0]).is( elementSelector )) {
observer.disconnect();
secondaryDomManipulation();
observer.observe(elementContainer, options);
break;
}
}
}
});
observer.observe(elementContainer, options);
};
Okay, so maybe not that simple, but it is a simplified version. Now let us work through the observer to define it in a way that works for your project.
Try to be narrow when choosing your elementContainer. Select the direct parent of the refreshing element. If you get Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'
it means the elementContainer
is likely either not there on runtime or you passed it a jQuery object. What you need is a native JS object. You can pull a native JS object from the DOM in a few ways. Using jQuery array notation it would look something like $('#refreshing-element-container')[0]
or with native JS it could be pulled using an id like this document.getElementById('refreshing-element-container')
.
Look out it’s a stack overflow! (No, not the site). The observer will run EVERY time your element is added. That includes if you append it using your secondaryDomManipulation function. It is important to call observer.disconnect();
before detaching or removing of a $('.refreshing-element')
. If your secondary manipulation of $('.refreshing-element')
is async, you should move observer.observe(elementContainer, options);
to the end of that call. Also, having two separate asynchronously loaded views on one page can get messy fast, as it can easily produce false positives, leaving you erroneously re-appending your elements.
This Mutation Observer is only watching for nodes being added, but if you want to observe removed nodes you can add mutation.removedNodes to the list. See David Walsh’s post on Mutation Observers for other configuration options including attribute mutations.
If you would rather use a plugin or script you can check out this CROmetrics article where they share a sweet jQuery plugin which defaults to a setInterval solution if MutationObserver is missing from a Browser’s default feature list. Also, the Optimizely docs have example code to source external scripts, as well as a number of other useful helper functions.
Not here to preach, but I think there’s something to writing these type of scripts yourself. Maybe so that you aren’t just wildly downloading libraries or so that you can make changes to the script as needed and only use the functions that are necessary for your code base.
In Conclusion…
You’ll run into a bunch of errors and it’ll be hard, but this is what developing is all about! Each Angular test you will build will need a solution that is a little different from the last and the next, but keep pushing forward. If you do get stuck or just want to say, “Hi!”, feel free to reach out to us by leaving us a note on our contact page. We’d love to help where and when we can. Good luck and happy testing.
Written by Darius Capizzi