Campaign Design API > Testing React content

Who is this article for?

  • Web Developers who build campaigns on React-based pages
  • Marketers working closely with Web Developers managing optimisation campaigns on React-based pages

Overview

React is a JavaScript library for creating user interfaces. Logical UI blocks on a page (a search form, calendar, basket, etc.) can be built and applied as a separate component on the UI. React is often mentioned in conjunction with Single Page Applications and it is often used on them. However, it can be used on any UI component on a web page.

Challenge: Oracle Maxymiser and testing React content

As the browser's DOM is considered slower than JavaScript, React uses its own Virtual DOM to perform all the interface processing operations and only then applies the changes to the browser DOM.

Oracle Maxymiser uses JavaScript to modify the DOM to create different test variant. As React's Virtual DOM and the browser DOM are tightly coupled, applying alternative content directly to the browser DOM may break React's architecture data flow and lead to unpredictable issues on the page.

Maxymiser provides 3 different solutions for testing React content, described below.

Solution 1: Target manipulations of static DOM nodes

Even though React binds its Virtual DOM to the browser's DOM, you can still make changes to the static nodes whenever they appear.

DOM manipulations should be applied as if it's done for an ordinary website. But the target node selection is limited to simple things like images, text, buttons, etc. These changes don't require any specific integration with the site, even if the site is built on React. You can eaither use the standard CD API methods or MutationObserver to handle the node appearance synchronously.

Note: MutationObserver is not available in browsers prior to Internet Explorer 11.

The only thing that would need to be addressed in addition is the SPA routing.

Solution 2: React Components and Elements interception

Oracle Maxymiser engineers have developed a client-side integration with React as Components and Elements may have dynamic nature and be hardly manipulated as if it was an image or plain text. Imagine a carousel with right and left arrows that needs an item to be deleted. That's where the described below integration helps.

The React integration module intercepts the default React API calls and applies page content changes before the UI components and elements are produced within React, thus avoiding inconsistencies between the Virtual and the browser DOMs.

The script decorates the original API method's in order to raise events which contain the component or element information at the time when the React application creates or modifies it. We can then handle those events with our custom JavaScript to create our own content variants.

Prerequisites

React variable should be made accessible for CD API scripts on the tested pages. The easiest way to do that — is to pass it to Maxymiser via a CD API's event. Example for the Babel-compiled source code aimed for Webpack bundling:

import React from 'react'

if ( window.mm_events ) {
window.mm_events.trigger( 'REACT_READY', {
React: React
} );
}

Maxymiser would then expose window.mm_events and accept React. The code above should run before any React Elements or Components are created in order to give the Maxymiser campaigns control over those.

Identify React's components and elements

Start from inspecting React Components with React Developer Tools. You can identify every component's state and props and study how you can use those in your campaign (and find the exact values that should be changed):

Here's the inspected component:

And its structure:

You can use the information above to modify the default content of the component (class, e.g. HelloMessage, Navigation, Basket, etc.) or an element (node, such as div, span, h2...)

Our goal is to override the arguments which are passed to React Component and React.createElement() in order to create different variants of the default user experience.

Intercept React's API calls

The code snippet below makes use of the exposed React variable. Make sure the snippet is executed only once per page (you can do this by using a site script in the Maxymiser UI). Once applied, campaigns on the page can intercept the React calls based on the triggered events and modify the React content before it is rendered.

Note: In case the React variable is passed to the CD API React module after any test areas become processed by React, those areas won't be changed by Maxymiser campaigns.

React Module

Object.defineProperty() method is used for React Components creation. React.createElement() method is responsible for creating individual DOM nodes. The following CD API script intercept the creation of both and exposes their arguments for modification:

// React Proxy v.1.4 window.mm_log=function(){localStorage.getItem("mm_log")&&console.log.apply(this,arguments)};window.mm_events=events;var publicEvtName="REACT_READY",publicSyncReactElement=function(f){try{if(f._owner._instance){var e=f._owner._instance;e.updater.enqueueForceUpdate(e)}else if(f._owner.stateNode){var h=f._owner.stateNode;h.updater.enqueueForceUpdate(h)}}catch(g){}}; if(!site.scope.reactPatchInit){var proxyReactComponent=function(f){return function(){var e=Array.prototype.slice.call(arguments);"object"===typeof e[0]&&"function"===typeof e[0].constructor&&events.trigger("React.Component",e[0]);return f.apply(this,e)}},proxyReactElement=function(f){return function(){var e=Array.prototype.slice.call(arguments);events.trigger("React.createElement",e);e=f.apply(this,e);site.scope.saveTheElementTo&&events.trigger("React.testedElement",e);return e}};site.scope.reactPatchInit= !0;mm_log(">>> RP React.Component patched");Object.defineProperty=proxyReactComponent(Object.defineProperty);events.on(publicEvtName,function(f){f=f.React;site.scope.React=f;mm_log(">>> RP React.createElement patched",f,f.createElement);f.createElement=proxyReactElement(f.createElement)})} modules.define("react",function(){function f(a){mm_log(">>> RP specs synced with the flagged items",a);g.components[a]&&(g.components[a].forEach(function(b){mm_log(">>> RP component re-applied",a);"function"===typeof b.componentWillUnmount&&b.componentWillUnmount();events.trigger("React.Component",b);"function"===typeof b.componentDidMount&&b.componentDidMount()}),n.components[a]=g.components[a],delete g.components[a]);g.elements[a]&&(g.elements[a].forEach(function(b){mm_log(">>> RP element re-rendered", a,b);publicSyncReactElement(b)}),n.elements[a]=g.elements[a],delete g.elements[a])}var e={rules:{},rendererRefs:{},campaignRefs:{},specs:{}},h={},g={components:{},elements:{}},n={components:{},elements:{}};var p={getProps:function(a){return{equal:function(b,c){return a[1]&&a[1][b]?a[1][b]===c:!1},match:function(b,c){return a[1]&&a[1][b]&&"string"===typeof a[1][b]?-1!==a[1][b].indexOf(c):!1}}}};var k={hide:function(a){a.forEach(function(a){var b=".mm-tested-area-"+a.replace(/\s/g,"-");mm_log(">>> RP content hide", b,a);e.rendererRefs[a].hide(b,a)})},show:function(a){a.forEach(function(a){mm_log(">>> RP content show",a);e.rendererRefs[a].show(a)})},getVariants:function(a){mm_log(">>> RP content request: ",a.toString());k.hide(a);a.forEach(function(a){e.campaignRefs[a].events.trigger("campaign.requested")});visitor.requestPage(a.toString()).done(function(){var b={};a.forEach(function(a){if(e.rendererRefs[a]){var c=e.campaignRefs[a].getName();e.campaignRefs[a].events.trigger("campaign.ariived");events.trigger("react.campaign-arrived", c);b[c]={campaign:e.campaignRefs[a],renderer:e.rendererRefs[a]}}});for(var c in b)if(b.hasOwnProperty(c)){var q=b[c].campaign.getExperience();Object.keys(q).forEach(function(a){b[c].campaign.getElement(a)&&!d.renderedElements[a]&&(d.renderedElements[a]=!0,b[c].renderer.runVariantJs(a))})}k.show(a)});k.trackReactCalls(a)},flagReactItem:function(a,b,c,e,d){mm_log(">>> RP marking item: ",b,c);"component"===b?g.components[a]?g.components[a].push(c):g.components[a]=[c]:"element"===b&&(d=a+"-"+d,c[1]&& "object"===typeof c[1]?(c[1]["data-mm-processed"]=c[1]["data-mm-processed"]?c[1]["data-mm-processed"]+(" "+d):d,c[1].className="string"===typeof c[1].className?c[1].className+(" "+e):e):c[1]={className:e,"data-mm-processed":d},site.scope.saveTheElementTo=a)},trackReactComponents:function(a){var b;for(b in h)if(h.hasOwnProperty(b)){var c=h[b].components;for(var d in c)if(c.hasOwnProperty(d)&&c[d](a))if(e.specs[b]&&e.specs[b].components[d])e.specs[b].components[d](a);else k.flagReactItem(b,"component", a)}},trackReactElements:function(a){var b;for(b in h)if(h.hasOwnProperty(b)){var c=h[b].elements;for(var d in c)c.hasOwnProperty(d)&&(-1!==d.indexOf("-singleton")&&a[1]&&a[1]["data-mm-processed"]&&-1!==a[1]["data-mm-processed"].indexOf(b+"-"+d)?mm_log(">>> RP element is already processed: ",b+"-"+d,a):c[d](a,p.getProps(a))&&(e.specs[b]&&e.specs[b].elements[d]?(mm_log(">>> RP element is processed: ",b,d),a=e.specs[b].elements[d](a),k.flagReactItem(b,"element",a,"mm-processed",d)):k.flagReactItem(b, "element",a,"mm-tested-area-"+b.replace(/\s/g,"-"))))}},trackReactCalls:function(a){mm_log(">>> RP tracking React calls for: ",a);k.tracking=!0;events.on("React.Component",k.trackReactComponents);events.on("React.createElement",k.trackReactElements);events.on("React.testedElement",function(a){var b=site.scope.saveTheElementTo;delete site.scope.saveTheElementTo;g.elements[b]?g.elements[b].push(a):g.elements[b]=[a];mm_log(">>> RP element saved: ",b,a)})}};var l={add:function(a,b){mm_log(">>> RP qualifiers added: ", a,b);l.list||(l.list={});l.list[a]=b},run:function(a){if(!l.list||!l.list[a]||l.list[a][0]())return mm_log(">>> RP qualified immediately: ",a),a;if(!l.list[a][1]()){var b=function(){l.list[a][0]()?c.resolve():l.list[a][1]()?c.reject():setTimeout(b,100)},c=new Deferred;d.deferredQualifications[a]=c;c.done(function(){mm_log(">>> RP qualified asynchronously: ",a);k.getVariants([a])}).fail(function(){mm_log(">>> RP not qualified asynchronously: ",a)});b();return!0}return!1}};var d={add:function(a,b,c){mm_log(">>> RP routes added: ", a,b);d.urlsList||(d.urlsList={},d.urlExclusions={},events.on("route.change",d.listen));d.urlsList[a]=b;"undefined"!==typeof c&&(d.urlExclusions[a]=c)},deferredQualifications:{},renderedElements:{},dispose:function(a,b){mm_log(">>> RP dispose element: ",a,b);b?d.deferredQualifications[a].fail(function(){delete h[a]}):(d.deferredQualifications[a]&&"pending"===d.deferredQualifications[a].state()&&d.deferredQualifications[a].reject(),delete h[a]);document.querySelectorAll('*[data-mm-detach-with="'+a+ '"]').forEach(function(b){mm_log(">>> RP node detached: ",a,b);b.parentNode.removeChild(b)})},matchUrl:function(a,b){var c;"string"===typeof b?c=b===a:"object"===typeof b?c=b.test(a):"function"===typeof b&&(c=b(a));return c},listen:function(a){mm_log(">>> RP route url checked: ",a,d.urlsList);var b=[];var c=!1;var f=a.replace(/[?#].*/g,"");d.renderedElements={};g={components:{},elements:{}};e.specs={};for(var m in d.urlsList)d.urlsList.hasOwnProperty(m)&&(c=d.urlsList[m].some(function(a){return d.matchUrl(f, a)}),a=d.urlExclusions[m]&&d.urlExclusions[m].some(function(a){return d.matchUrl(f,a)}),(a=c&&!a)?(mm_log(">>> RP checking configs for: ",m),h[m]=e.rules[m],c=l.run(m),"string"===typeof c&&b.push(c),mm_log(">>> RP qualification result for element: ",m,c)):c=!1,a&&!0!==c||d.dispose(m,c));b.length&&k.getVariants(b)}};this.addRules=function(a){mm_log(">>> RP campaign rules added: ",a);a.campaign.getName();for(var b in a.rules)a.rules.hasOwnProperty(b)&&(e.rendererRefs[b]=a.renderer,e.campaignRefs[b]= a.campaign,e.rules[b]=a.rules[b],d.add(b,a.rules[b].urls),e.rules[b].qualification&&l.add(b,e.rules[b].qualification));mm_log(">>> RP campaign renderers added: ",e)};this.addSpecs=function(a){mm_log(">>> RP campaign specs added: ",a);for(var b in a.specs)if(a.specs.hasOwnProperty(b)){e.specs[b]=a.specs[b];var c=d.deferredQualifications[b];c?(c.done(function(){f(b)}),c.fail(function(){delete g.components[b];delete g.elements[b]})):f(b)}}});

Map it to all the pages where you want React integration to run. With the lowest mapping order value, meaning that it runs first.

SPA Routing Module

You can use the following modification of SPA Router module that is synced up with internal events of the React module:

// SPA React Router v.1.0 if(!site.scope.reactRouterInit){var proxyRouteChange=function(a){return function(){var b=a.apply(this,arguments);triggerRouteChange(window.location.href);return b}},triggerRouteChange=function(a){a&&lastUrl!==a&&(lastUrl=a,events.trigger("route.change",a))};site.scope.reactRouterInit=!0;var lastUrl="";history.pushState=proxyRouteChange(history.pushState);history.replaceState=proxyRouteChange(history.replaceState);window.addEventListener("popstate",function(a){triggerRouteChange(window.location.href)}); window.addEventListener("hashchange",function(a){triggerRouteChange(window.location.href)});triggerRouteChange(window.location.href)};

Map it to all the pages where you want the React integration to run. With the highest mapping order value, meaning that it runs last.

Syntax

Campaigns can still be coded using standard content changes. Whenever you need the React entities to be modified though, you can put the component/element identification rules into a campaign script (and map it to all the React pages):

react.addRules( { // do not change these 2 lines 'renderer': renderer, 'campaign': campaign, // user-editable configurations 'rules': { // custom campaign and virtual page name 'T01_ProductCarousel': { // URLs can be passed as strings, regex or // functions that return either true or false 'urls': [ /.*\/product-description\/.*/ ], 'elements': { // custom React element name 'CarouselImages': function( args, props ) { var tagName = args[0], props = args[1]; // whenever the condition returns true, the created // element is marked as the one to be replaced return props && props.images; } } } } } );

Variants should be mapped to the virtual page mask from the "rules" section (i.e., *T01_ProductCarousel*):

<script> react.addSpecs( { // user-editable configurations 'specs': { 'T01_ProductCarousel': { 'elements': { 'CarouselImages': function( args ) { var carouselImages = args[1].images; // remove videos from the product images carousel if ( carouselImages && carouselImages instanceof Array ) { carouselImages.forEach( function( image, index ) { if ( image.type === 'video' ) { carouselImages.splice( index, 1 ); } } ); } // return the new arguments // do not change this line return args; } } } } } ); </script>

Build Campaigns on React content

The following section provides examples of granular changes you can perform with the help of the integration described above. A pre-defined CD API event is triggered for every React entity (Component or Element), so to make a modification you need to assign a corresponding identification rule and replacement specification for the arguments you need to change.

Override React Component

First off let's start by looking at a sample React page with help of React Developer Tools:

And change the "Timer" component just for the sake of example. Make a right click on the <Timer> label and choose "Show Source".

Take a look at the source code used to render the default content:

Here's the original Babel (ES6 and JSX) code:

class Timer extends React.Component { constructor(props) { super(props); this.state = {secondsElapsed: 0}; } componentDidMount() { this.timerID = setInterval( () => this.tick(), 1000 ); } componentWillUnmount() { clearInterval(this.timerID); } tick() { this.setState({ secondsElapsed: this.state.secondsElapsed+1 }); } render() { return ( <div> Seconds Elapsed: {this.state.secondsElapsed} </div> ); } } ReactDOM.render( <Timer />, document.getElementById('react-container') );

And here are modifications made as an example.

Campaign script:

react.addRules( { // do not change these 2 lines 'renderer': renderer, 'campaign': campaign, // user-editable configurations 'rules': { // custom campaign and virtual page name 'T02_Timer': { // URLs can be passed as strings, regex or // functions that return either true or false 'urls': [ /.*\/react\// ], 'components': { // custom React element name 'Timer': function( component ) { return component.type.name === 'Timer'; } } } } } );

Variant code:

<script> react.addSpecs( { // user-editable configurations 'specs': { 'T02_Timer': { 'components': { 'Timer': function( component ) { var componentSpec = component.type.prototype; componentSpec.componentDidMount = function componentDidMount() { var _this = this; this.setState({ secondsElapsed: 10 }); this.interval = setInterval(function () { return _this.tick(); }, 1000); }; componentSpec.tick = function tick() { if ( this.state.secondsElapsed > 0 ) { this.setState({ secondsElapsed: this.state.secondsElapsed - 1 }); } }; componentSpec.render = function render() { return React.createElement( 'div', null, 'Seconds Remaining: ', this.state.secondsElapsed ); }; } } } } } ); </script>

And here's how it affects the page:

Default Alternative

Override React Element's content

Let's find a HTML node created with React:

We would need the following code that replaces the text:

react.addRules( { // do not change these 2 lines 'renderer': renderer, 'campaign': campaign, // user-editable configurations 'rules': { // custom campaign and virtual page name 'T03_TodoText': { // URLs can be passed as strings, regex or // functions that return either true or false 'urls': [ /.*\/react\// ], 'elements': { // custom React element name 'Text': function( args ) { var tagName = args[0], child_0 = args[2]; return tagName === 'h3' && child_0 === 'TODO'; } } } } } );

With the variant:

<script> react.addSpecs( { // user-editable configurations 'specs': { 'T03_TodoText': { 'elements': { 'Text': function( args ) { args[2] = 'Tasks'; } } } } } ); </script>

Which results in:

Default Alternative

Override React Element's props

Let's add an additional item to the list:

Here's the source code that creates it:

We should target React.createElement() call from the line #49 to add a new item similarly to how every item is attached.

react.addRules( { // do not change these 2 lines 'renderer': renderer, 'campaign': campaign, // user-editable configurations 'rules': { // custom campaign and virtual page name 'T04_TodoItem': { // URLs can be passed as strings, regex or // functions that return either true or false 'urls': [ /.*\/react\// ], 'elements': { // custom React element name 'ListItem': function( args ) { var child_0 = args[2]; return args.length === 6 && child_0 === 'To-do list:'; } } } } } );

And the variant:

<script> react.addSpecs( { // user-editable configurations 'specs': { 'T04_TodoItem': { 'elements': { 'ListItem': function( args ) { // Add a new argument at position 4 args.splice( 4, 0, React.createElement( 'div', { 'className': 'line red' }, 'New Task' ) ); } } } } } ); </script>

Which results in:

Solution 3: Develop alternative experiences in React sources codebase

Complex page modifications can be arranged in the React sources. In this case Maxymiser platform makes the decision for the test variation to be rendered and gathers statistics. Rendering itself originates from the server's codebase.

This can be achieved in different ways. Two most used solutions are:

  1. Making direct calls to the decision making REST API end-points: Campaign Delivery API
  2. Switching content combination with help of an exposed JavaScript method