Code snippets
FreeCROTool Helper Functions for Hand-Coded A/B Tests
★★★ Technical level 3 of 3
Written by Neil Webley · Last updated
A developer reference for FreeCROTool helpers, DOM transforms, MutationObserver patterns, SPA-safe event handlers and analytics events.
This is the hand-coded workbench
This reference is specifically for developers writing JavaScript experiments in FreeCROTool's code editor. If you use the visual editor, you already have a purpose-built route for changing copy, styles and page elements, no need to bring a MutationObserver to a perfectly good point-and-click job.
Here you will find FreeCROTool's developer-facing helper functions, the lifecycle rules behind them, and reusable patterns for asynchronous pages, single-page applications and analytics events.
How FreeCROTool hand-coded experiments run
A hand-coded test has Global JavaScript and JavaScript for each variant. When a visitor qualifies, FreeCROTool assigns a variant, runs the global code, and then runs that variant's code inside the same function scope. The platform retains the assignment in a first-party test cookie and adds the test and variant identifiers to FreeCROTool analytics events.
- Put shared setup functions in Global JavaScript.
- Put the actual control or variant behaviour in the relevant variant.
- Do not fire a conversion merely because the experiment code loaded. Fire it only after the visitor performs the measured action.
- Assume modern pages can replace DOM nodes without a full reload. Make transforms and listeners idempotent.
- Use Staging and a pinned variant before publishing.
Code saved in the FreeCROTool test editor is processed when it is published. You do not need to look up or hard-code the experiment ID: write --testID-- wherever your hand-coded test needs its own ID, and FreeCROTool replaces it with the real ID during publishing. The placeholder is case-sensitive, including the capital letters ID.
var thisTestID = '--testID--';
var marker = 'fcro-test-' + thisTestID + '-applied';
Event helpers already receive the test ID
In the FreeCROTool code editor, call cro_tests.fireEvent(type, data), cro_tests.fireClickEvent(data), cro_tests.firePageVisit(data) and cro_tests.firePurchaseEvent(data) exactly as shown on this page. Publishing automatically inserts the test ID as their first runtime argument, so do not also pass --testID-- to those shorthand calls. Code running outside the test editor must use the full runtime signature, such as window.cro_tests.fireClickEvent(TEST_ID, data).
Exception: the main-tag initialisation script needs the real ID
The initialisation script in the project's main FreeCROTool tag is shared project code, so the publisher cannot know which experiment an event belongs to. --testID-- is not replaced there. Supply the intended test's real numeric ID explicitly whenever that script calls a test-specific helper.
// In the main-tag initialisation script:
var testID = 1234567890123;
window.cro_tests.firePurchaseEvent(testID, {
name: 'purchase',
transaction_id: completedOrder.id,
currency: completedOrder.currency,
value: Number(completedOrder.value)
});
FreeCROTool helper-function summary
Styles and visibility
| Helper | Purpose | Important behaviour |
|---|---|---|
cro_tests.addCSS(id, css) | Add or update a named stylesheet. | Reuses the same style element ID, making repeated calls safe. |
cro_tests.removeCSS(id) | Remove a named stylesheet immediately. | Does nothing if the ID is absent. |
cro_tests.removeCSSAfterSeconds(id, seconds) | Schedule removal of a named stylesheet. | The delay is in seconds. |
cro_tests.hideElements(id, css, duration) | Temporarily add hiding CSS with a safety timeout. | The duration is in milliseconds. Use a unique ID. |
cro_tests.revealElements(id) | Remove CSS created by hideElements. | Reveal as soon as the transform is ready; retain the timeout as a fallback. |
Tracking and conversions
| Helper | Purpose | Important behaviour |
|---|---|---|
cro_tests.fireClickEvent(data) | Send a freecro_click event for this test. | Use a stable, descriptive name. |
cro_tests.firePageVisit(data) | Send a freecro_page event for a meaningful page or SPA state. | Do not fire for every minor UI change. |
cro_tests.firePurchaseEvent(data) | Send a freecro_purchase event with revenue. | Use confirmed order data and a stable transaction ID. |
cro_tests.fireEvent(type, data) | Send another FreeCROTool event type. | Prefer the named wrappers where one fits. |
DOM elements and event handlers
| Helper | Purpose | Important behaviour |
|---|---|---|
cro_tests.addListener(elements, event, handler) | Add a native event listener to current elements. | It does not watch for later elements or prevent duplicate listeners. |
cro_tests.waitForElement(selector, timeoutMs, root) | Wait for the first matching DOM element. | Returns a one-shot Promise and disconnects before resolving. |
cro_tests.elementInViewport(selector, options) | Wait until an element enters the viewport. | Returns a one-shot Promise; timeout is optional. |
Use waitForElement for a one-shot asynchronous transform. Use the repeatable MutationObserver pattern below when an SPA can replace the element more than once.
Cookies and short-lived state
| Helper | Purpose | Important behaviour |
|---|---|---|
cro_tests.cookie.get(name) | Read a FreeCROTool-domain cookie value. | Returns an empty string when absent. |
cro_tests.cookie.set(name, value, days) | Set a cookie for the configured project domain. | Expiry is measured in days; omitting it creates a session cookie. |
cro_tests.cookie.setSeconds(name, value, seconds) | Set a short-lived cookie. | Expiry is measured in seconds; omitting it creates a session cookie. |
Script loading, consent and debugging
| Helper | Purpose | Important behaviour |
|---|---|---|
cro_tests.loadJS(url, callback) | Load an external JavaScript file asynchronously. | The callback receives (error, scriptElement). |
cro_tests.enableTesting() | Enable evaluation when the project waits for consent. | Call only from the site's approved consent flow. |
cro_tests.lg(...values) | Write a FreeCROTool-prefixed diagnostic message. | Useful during Staging; remove noisy logs before launch. |
Only the helpers documented on this page are intended for hand-coded experiments. Other properties and functions visible on window.cro_tests manage test assignment, rule evaluation and runtime state. Treat them as internal implementation details and do not call or modify them from experiment code.
CSS and page-hiding helpers
addCSS, removeCSS and removeCSSAfterSeconds
Use a test-specific style ID so your variant can update or remove only its own CSS.
var styleId = 'fcro-test-42-pricing';
cro_tests.addCSS(styleId, [
'.pricing-card__cta {',
' background: #14532d;',
' color: #fff;',
'}'
].join('\n'));
// Optional cleanup:
cro_tests.removeCSSAfterSeconds(styleId, 30);
addCSS is idempotent by ID: a second call updates the existing style element rather than creating another. Read the focused guides to add custom A/B test CSS and remove it safely.
hideElements and revealElements
Hide only the part of the page that would visibly flicker, then reveal it immediately after the transform. The duration provides a safety escape if the target never appears.
var hideId = 'fcro-test-42-hide-hero';
cro_tests.hideElements(
hideId,
'.hero { visibility: hidden !important; }',
1500
);
cro_tests.waitForElement('.hero__title', 1500).then(function (title) {
if (title) {
title.textContent = 'A clearer headline';
}
cro_tests.revealElements(hideId);
});
Do not use a long whole-page hide as a substitute for resilient code. See how to prevent page flicker, reveal changes when ready, and keep an independent safety timeout.
Click, page and purchase event helpers
These helpers only send a conversion when the visitor has an assignment cookie for the test. FreeCROTool adds the experiment ID, variant number, combined variant ID and test status before routing the event to the project's GA4 stream.
fireClickEvent
cro_tests.fireClickEvent({
name: 'pricing_demo_requested'
});
Fire this from the actual click or activation handler. Keep the name stable after launch so the same action is not split across report rows. For broader GA4 and GTM choices, read the A/B test click-tracking guide.
firePageVisit
cro_tests.firePageVisit({
name: 'checkout_delivery_step'
});
Use a page event for a meaningful route or SPA state, not a tooltip opening or a field receiving focus. If the application can render the same state repeatedly, store the last tracked route or state before firing again.
firePurchaseEvent
var order = window.completedOrder;
var transactionId = String(order && order.id || '');
if (transactionId) {
var sentKey = 'fcro-purchase-' + transactionId;
if (sessionStorage.getItem(sentKey) !== '1') {
cro_tests.firePurchaseEvent({
name: 'purchase',
transaction_id: transactionId,
currency: String(order.currency).toUpperCase(),
value: Number(order.value)
});
sessionStorage.setItem(sentKey, '1');
}
}
Run this only when the order is confirmed. A refresh must reuse the same transaction ID; a later genuine order must use a new one. Do not use Date.now() as the order ID and do not send personal information. The full GA4 purchase-revenue guide covers GTM, deduplication, repeat buyers and Shopify.
fireEvent
The generic editor signature is cro_tests.fireEvent(type, data). At runtime it becomes window.cro_tests.fireEvent(testID, type, data). It creates an event named freecro_ plus the supplied type. Use lowercase, documented types and prefer fireClickEvent, firePageVisit or firePurchaseEvent whenever they describe the action accurately.
cro_tests.fireEvent('signup', {
name: 'newsletter_signup_completed'
});
How do I wait for an element to be in the DOM before transforming it?
waitForElement
cro_tests.waitForElement(selector, timeoutMs, root) returns a Promise. It checks for the element immediately, otherwise watches the DOM with a MutationObserver. It disconnects the observer before resolving, so transformations inside .then() cannot trigger the wait again.
cro_tests.waitForElement(
'[data-test="delivery-message"]',
3000
).then(function (message) {
if (!message) return;
message.textContent = 'Free delivery over £40';
});
Transform immediately when the element already exists
waitForElement checks synchronously and does not create a MutationObserver when it finds the element immediately. Its Promise callback still runs in a later microtask, however. For a flicker-sensitive change, query first and transform synchronously; only call waitForElement when the element is genuinely absent.
function transformDeliveryMessage(element) {
if (element.dataset.fcroDeliveryTransformed === '1') return;
element.dataset.fcroDeliveryTransformed = '1';
element.textContent = 'Free delivery over £40';
}
var existing = document.querySelector('.delivery-message');
if (existing) {
// Transform now, in the current JavaScript task.
transformDeliveryMessage(existing);
} else {
// Attach the observer only when waiting is necessary.
cro_tests.waitForElement('.delivery-message', 3000)
.then(function (element) {
if (!element) return;
transformDeliveryMessage(element);
});
}
This pattern is useful for hero copy, prices, calls to action and other above-the-fold elements where even a microtask-sized delay is unnecessary. The data attribute makes the transformation idempotent if surrounding experiment code runs again. It remains a one-shot pattern: use the repeatable observer example below when an SPA may replace the node later.
The Promise resolves with the first matching element, or null when the timeout expires or the selector is invalid. The timeout defaults to 5,000 milliseconds. Pass a stable container as the optional third argument to observe a smaller part of the page:
var checkout = document.querySelector('[data-test="checkout"]');
cro_tests.waitForElement(
'[data-test="delivery-message"]',
3000,
checkout
).then(function (message) {
if (!message) return;
message.classList.add('fcro-delivery-message-ready');
});
This helper is intentionally one-shot. If a SPA may destroy and recreate the element, call it again after a known route change or use the repeatable transform pattern below. Avoid an unbounded setInterval; if polling is unavoidable, clear it on success and after a maximum duration. See the shorter dynamic-elements guide.
Apply a transform once per DOM node, even after rerenders
function transformDeliveryMessages(root) {
var scope = root || document;
scope.querySelectorAll('[data-test="delivery-message"]').forEach(function (element) {
if (element.dataset.fcroTest42Applied === '1') return;
element.dataset.fcroTest42Applied = '1';
element.textContent = 'Free delivery over £40';
});
}
transformDeliveryMessages(document);
var transformObserver = new MutationObserver(function (records) {
records.forEach(function (record) {
record.addedNodes.forEach(function (node) {
if (node.nodeType !== 1) return;
if (node.matches('[data-test="delivery-message"]')) {
transformDeliveryMessages(node.parentElement || document);
} else {
transformDeliveryMessages(node);
}
});
});
});
transformObserver.observe(document.body, {
childList: true,
subtree: true
});
The data-fcro-test42-applied marker makes the transform idempotent for each node. A newly rendered replacement node is transformed once; unrelated DOM mutations do not repeatedly rewrite the existing element. Narrow selectors and observers to the smallest stable container available.
How do I trigger when an element enters the viewport?
cro_tests.elementInViewport(selector, options) first waits for the element to exist, then observes its intersection with the browser viewport. It resolves once with the element and disconnects before .then() runs. timeoutMs is optional: when it is omitted, the helper keeps watching until the element enters view or the page unloads.
cro_tests.elementInViewport(
'[data-test="pricing-table"]',
{
threshold: 0.5
}
).then(function (pricingTable) {
if (!pricingTable) return;
cro_tests.fireEvent('view', {
name: 'pricing_table_viewed'
});
});
This example fires when at least 50% of the pricing table is visible. Because the observer is one-shot, scrolling the same element out of view and back again does not fire it a second time.
Viewport options
| Option | Default | Purpose |
|---|---|---|
timeoutMs | No timeout | Optional total time allowed for the element to appear and enter view. Omit it to observe for the lifetime of the page. |
threshold | 0 | Visible proportion from 0 to 1. Use a meaningful amount such as 0.5 for content exposure. |
root | Browser viewport | An optional scrollable ancestor used as the intersection viewport. |
rootMargin | 0px | Expands or contracts the effective viewport using IntersectionObserver margin syntax. |
searchRoot | document | An optional stable container in which to find the selector. |
To allow up to one hour, supply the duration in milliseconds:
cro_tests.elementInViewport('.long-article__conclusion', {
threshold: 0.5,
timeoutMs: 60 * 60 * 1000
}).then(function (element) {
if (!element) return;
cro_tests.fireEvent('view', {
name: 'article_conclusion_viewed'
});
});
Use indefinite observers deliberately
An observer without a timeout can be appropriate for stable content that a visitor may reach much later. It can also remain active forever when the selector is wrong, the content never renders, or an SPA removes the original target before it enters view. Repeated setup can create several observers and duplicate conversions. Use a test-specific installation guard, prefer a stable selector, and set a generous timeout for volatile SPA components or optional content.
Seeing one pixel is often too weak to call a meaningful conversion. Choose a threshold that matches the hypothesis and consider whether the element should remain visible for a minimum duration before recording an exposure. Treat content views as supporting evidence unless viewing the content is genuinely the experiment's success criterion.
If setup code itself can run more than once, guard the call with a test-specific flag so separate observers cannot send duplicate events:
if (!window.__fcroPricingViewportObserverStarted) {
window.__fcroPricingViewportObserverStarted = true;
cro_tests.elementInViewport('[data-test="pricing-table"]', {
threshold: 0.5
}).then(function (element) {
if (!element) return;
cro_tests.fireEvent('view', {
name: 'pricing_table_viewed'
});
});
}
How do I attach a click event handler once in an SPA?
Best default: install one delegated listener
Event delegation survives DOM replacement because the listener lives on a stable ancestor. Guard the installation with a test-specific global flag so re-executed setup code cannot attach it again.
if (!window.__fcroTest42ClickHandlerInstalled) {
window.__fcroTest42ClickHandlerInstalled = true;
document.addEventListener('click', function (event) {
var button = event.target.closest('[data-test="pricing-cta"]');
if (!button) return;
cro_tests.fireClickEvent({
name: 'pricing_cta_clicked'
});
});
}
If the control is not a native button or link, also handle keyboard activation and preserve its accessible role. Do not call preventDefault() unless the experiment deliberately replaces the original behaviour.
When to use addListener
cro_tests.addListener(selectorOrElements, event, handler) is concise for elements that already exist and will not be replaced:
cro_tests.addListener('[data-test="static-cta"]', 'click', function () {
cro_tests.fireClickEvent({
name: 'static_cta_clicked'
});
});
It queries selector strings immediately. It does not observe future nodes and it does not deduplicate handlers if called twice. Use delegation for an SPA, or mark each element before calling it. The focused JavaScript click-handler guide provides a shorter checklist.
Track an SPA state once
FreeCROTool reevaluates tests after pushState, replaceState, popstate and hashchange. Your own page-conversion code should still deduplicate the state it reports:
function trackCheckoutState() {
var stateKey = location.pathname + location.search + location.hash;
if (window.__fcroTest42LastTrackedState === stateKey) return;
if (location.pathname === '/checkout/' && location.hash === '#delivery') {
window.__fcroTest42LastTrackedState = stateKey;
cro_tests.firePageVisit({
name: 'checkout_delivery_step'
});
}
}
trackCheckoutState();
window.addEventListener('popstate', trackCheckoutState);
window.addEventListener('hashchange', trackCheckoutState);
Script, cookie and consent helpers
loadJS
cro_tests.loadJS('https://example.com/approved-library.js', function (error) {
if (error) {
cro_tests.lg('Library failed to load', error.message);
return;
}
cro_tests.lg('Library loaded');
});
Only load scripts your organisation has approved. Account for Content Security Policy, consent, performance and the risk that a third-party script fails. Avoid loading a second copy of a library the site already owns.
cookie.get, cookie.set and cookie.setSeconds
cookie.set measures its optional validity period in days. cookie.setSeconds measures the same optional argument in seconds. If the validity argument is omitted, either method creates a session cookie with no explicit expires value; the browser normally removes it when the browsing session ends, although session-restoration features can preserve session cookies.
| Call | Validity | Typical use |
|---|---|---|
cookie.set(name, value) | Browser session | State that should not have a fixed persistence period. |
cookie.set(name, value, 14) | 14 days | Remembering experiment-specific state across later visits. |
cookie.setSeconds(name, value, 300) | 300 seconds (five minutes) | Short guards, cooldowns and temporary workflow state. |
FreeCROTool's 60-day runtime default is not a default for your custom cookies. CROObject currently passes cro_tests.defaultCookiePeriod, normally 60 days, when it writes its own assignment cookies. A hand-coded call to cookie.set(name, value) does not inherit those 60 days; pass the third argument explicitly when your custom cookie must persist.
var seen = cro_tests.cookie.get('fcro_test42_seen');
if (!seen) {
cro_tests.cookie.set('fcro_test42_seen', '1', 14);
}
// A five-minute guard:
cro_tests.cookie.setSeconds('fcro_test42_guard', '1', 300);
An expiry is an upper limit, not a guarantee: visitors can clear cookies, browsers can restrict storage, and consent changes may require removal. These helpers write on the FreeCROTool project's configured cookie domain with path=/. Values are not automatically encoded. Keep them small, non-sensitive and uniquely named. Do not overwrite cookies beginning _cro., _cro_active_tests, _cro_noeval. or freeCROToolMode; the runtime owns those names.
enableTesting
Some projects are configured not to evaluate experiments until a consent manager grants the chosen category. In that integration, call window.cro_tests.enableTesting() only after the relevant consent is valid. Do not call it simply because a banner was dismissed. Follow the cookies and consent guide.
lg
cro_tests.lg() prefixes console output with cro_tests :, which makes staging diagnostics easier to filter.
cro_tests.lg('Test 42 found the pricing component');
Common hand-coded experiment mistakes
- The transform never runs: the selector is queried before the component renders. Use the bounded observer pattern.
- A click records twice: setup ran more than once and attached duplicate listeners. Use one delegated listener with a global installation guard.
- A SPA rerender removes the change: the application replaced the transformed node. Observe the stable container and mark each replacement node.
- A purchase fires on page refresh: tracking has no genuine transaction-ID guard.
- Control is not truly control: shared Global JavaScript changes the experience before variant code runs.
- The page stays hidden: the normal transform path failed and there was no independent reveal timeout.
- Events appear during setup: conversion helpers were called at the top level instead of inside the actual user or order callback.
Before publishing a hand-coded FreeCROTool test
- Test control and every variant in Staging, including the pinned-variant behaviour.
- Test the first page load, back/forward navigation, client-side route changes and component rerenders.
- Use stable selectors owned by the site where possible.
- Confirm each handler and conversion fires once per intended action.
- Confirm a second legitimate action or order can still fire when it should.
- Disconnect observers that only need one result and bound every polling fallback.
- Check keyboard use, focus order, screen-reader names, contrast and reduced-motion behaviour.
- Check consent, CSP errors, analytics DebugView and slower network conditions.
- Ask another developer to review selectors, cleanup, failure paths and analytics names.
For the surrounding workflow, start with creating an A/B test, configure precise test locations and audiences, then use the results interpretation guide before choosing a winner.