Utilities (avsb.utils)
avsb.utils is a toolkit of helpers built for experiment code. It covers six areas: Wait, Timing, DOM, Events, Data, and Log.
A variation is the version of the page a visitor sees in a test: control, or one of the challengers. Every helper here is also available as options.utils inside trigger and variation functions. Use options.utils there, and A vs B cleans up after itself. It tears down every observer and listener it made, the moment the variation is removed or a visitor navigates inside a single-page app. No manual cleanup needed.
Prefer options.utils.* over avsb.utils.* inside trigger and variation functions. Only options.utils cleans up automatically when the variation ends, which avoids memory leaks and stale callbacks on pages that navigate without a full reload.
Several examples below hand off to page code of your own. These are the stand-ins they assume, so you can see what each one is expected to take:
declare function showPaymentForm(): voiddeclare function trackSearch(term: string): voiddeclare function updatePrices(data: unknown): voiddeclare function updateStickyHeader(): voiddeclare function openUpsellModal(productId: string | undefined): voiddeclare function applyCheckoutVariant(): voiddeclare function skipIntroAnimation(): voiddeclare const reviewsHtmlFromApi: stringdeclare const trustedHtmlTemplate: stringWait
Helpers that wait for the DOM or a JavaScript value to be ready. Each one returns an AvsbGuardedThenable. Its .catch() is optional: a failure is logged instead of throwing an unhandled error.
utils.waitUntil(target, opts?)
The primary wait primitive. Waits for a CSS selector, a window.x.y global path, or a predicate function to resolve, then chains with .then(). Pass an array of targets to wait for all of them at once. Default timeout is 10 000 ms.
// CSS selector: resolves with the elementoptions.utils.waitUntil<HTMLElement>('.price-block').then((el) => { el.textContent = 'From $9/mo'})// window path: resolves with the valueoptions.utils.waitUntil<Record<string, unknown>[]>('window.dataLayer').then((dl) => { dl.push({ event: 'exp_viewed' })})// Predicate function: resolves with the return valueoptions.utils.waitUntil(() => window.Stripe?.isReady).then(() => { showPaymentForm()})// Array of targets: resolves with an array of resultsoptions.utils .waitUntil<[HTMLElement, { firstName: string }]>(['.cart', 'window.user']) .then(([cart, user]) => { const name = cart.querySelector<HTMLElement>('.name') if (name) name.textContent = user.firstName })See the Wait Until reference for the full parameter table, timeout options, and migration notes from the old callback API.
utils.poll(target, opts?)
Alias of waitUntil. Provided to ease copy-paste migration from Qubit experiment code that used a poll() helper. Identical in every other respect.
// Qubit-style code: works without modificationoptions.utils.poll<HTMLElement>('.hero', { timeout: 5000 }).then((el) => { el.classList.add('variant')})utils.waitForElement(selector, opts?)
Focused version of waitUntil for a single CSS selector. Supports a custom root element for scoped queries (including shadow DOM roots). Resolves with the matched Element.
// Scope the search to a shadow rootconst host = document.querySelector('checkout-widget')if (host?.shadowRoot) { options.utils.waitForElement('button[type="submit"]', { root: host.shadowRoot, timeout: 8000, }).then((btn) => { btn.textContent = 'Place Order' })}utils.onMutation(selector, callback, opts?)
Runs callback(el) for every element matching selector that is present now and for every new match added to the DOM in the future via a MutationObserver. Useful for dynamically-rendered lists or carousels where new items appear after the initial page load.
Supports shadow DOM via opts.root. Pass opts.once: true to fire only on the first match. Pass opts.attributes: true to also fire when attributes on matching elements change.
// Badge every product card that arrives, now and in the futureconst handle = options.utils.onMutation('.product-card', (card) => { const badge = document.createElement('span') badge.className = 'variant-badge' badge.textContent = 'New' card.prepend(badge)})// Stops automatically on variation removal; call this to stop it early:handle.stop()Timing
Utilities for controlling when and how often functions run. All returned handles are auto-cancelled when the variation is removed.
utils.once(fn)
Wraps a function so it executes at most once. Subsequent calls are silently ignored. Returns a wrapped version of the original function with the same signature.
const heroSection = document.querySelector<HTMLElement>('.hero-section')// Annotate the parameter: `once` preserves whatever signature you give it.const applyOnce = options.utils.once((el: HTMLElement) => { el.classList.add('highlighted')})// Calling multiple times is safe; only the first call takes effectif (heroSection) { applyOnce(heroSection) applyOnce(heroSection)}utils.throttle(fn, ms)
Returns a throttled version of fn that fires at most once per ms milliseconds. Comes with a .cancel() method to discard a pending invocation.
const stickyBanner = document.querySelector<HTMLElement>('.sticky-banner')const onScroll = options.utils.throttle(() => { const y = window.scrollY stickyBanner?.classList.toggle('visible', y > 300)}, 100)window.addEventListener('scroll', onScroll)utils.debounce(fn, ms)
Returns a debounced version of fn that waits ms milliseconds after the last call before firing. Comes with a .cancel() method.
const searchInput = document.querySelector<HTMLInputElement>('#search')const onInput = options.utils.debounce((e: Event) => { if (e.target instanceof HTMLInputElement) trackSearch(e.target.value)}, 300)searchInput?.addEventListener('input', onInput)utils.raf(fn)
Schedules fn with requestAnimationFrame and returns a { cancel() } handle. Use for DOM writes that should happen on the next paint.
options.utils.raf(() => { // Runs on the next animation frame; minimises layout thrash const hero = document.querySelector<HTMLElement>('.hero') hero?.style.setProperty('--accent', '#e63')})utils.idle(fn, opts?)
Schedules fn via requestIdleCallback (or a setTimeout fallback). Accepts an optional timeout so the callback runs even if the browser is never truly idle. Returns a { cancel() } handle.
// Track an impression without blocking the critical pathoptions.utils.idle(() => { avsb.track.event(42)}, { timeout: 2000 })utils.domReady()
Returns an AvsbGuardedThenable that resolves when DOMContentLoaded has fired (or immediately if it already has). Useful when variation code may run before the document is fully parsed.
options.utils.domReady().then(() => { // Safe to query the full DOM here const footer = document.querySelector('footer') const ctaBanner = options.utils.createElement('div', { class: 'cta-banner', text: 'Start your free trial', }) if (footer) footer.prepend(ctaBanner)})utils.retry(fn, opts?)
Calls an async or sync function repeatedly until it resolves without throwing. Options: attempts (default 3), delay in ms before the next try (default 200), and factor to multiply that delay by after each failure (default 2, so waits grow 200ms, 400ms, 800ms...). Returns a native Promise.
options.utils.retry( (): Promise<unknown> => fetch('/api/prices').then((r) => r.json()), { attempts: 3, delay: 500, factor: 2 }).then((data) => { updatePrices(data)})DOM
Helpers for reading and changing the DOM. Use these instead of raw browser methods: they never throw on a missing element. setHtml also cleans third-party HTML by default, so it cannot run a hidden script.
utils.$(selector, root?) and utils.$$(selector, root?)
Shorthand for querySelector and querySelectorAll. Both accept an optional root, so the search only looks inside it (useful for shadow DOM). $$ returns a plain array, not a NodeList.
const hero = options.utils.$('.hero-section')const cards = options.utils.$$('.product-card')cards.forEach((card) => { card.classList.add('variant-card')})utils.createElement(tag, props?, children?)
Creates an HTMLElement from a tag name, optional props object (class, id, text, html, attrs, style, on), and an optional array of child nodes or strings.
const badge = options.utils.createElement('span', { class: 'sale-badge', text: 'Sale', style: { background: '#e63', color: '#fff' },})const priceEl = options.utils.$('.price')if (priceEl) options.utils.insertBefore(badge, priceEl)utils.insertAfter(newNode, ref) and utils.insertBefore(newNode, ref)
Insert newNode immediately after or before a reference node. Equivalent to the native after() / before() DOM methods with explicit node arguments.
utils.wrap(node, wrapper)
Wraps node inside wrapper, preserving its position in the DOM. The wrapper takes the node's original position and the node becomes the wrapper's child.
const div = options.utils.createElement('div', { class: 'highlight-ring' })const ctaButton = options.utils.$('.cta-button')if (ctaButton) options.utils.wrap(ctaButton, div)utils.remove(node)
Removes a node from the DOM. Safe to call on a node with no parent, or one already removed: nothing happens.
utils.setText(node, text)
Sets the textContent of a node. Safe for user-supplied strings; text is never interpreted as HTML.
utils.setHtml(node, html, opts?)
Sets the innerHTML of an element. By default, inline scripts and event handlers are scrubbed before insertion to prevent accidental XSS from third-party data. Pass { raw: true } to bypass sanitisation when you fully control the HTML source.
// Safe: scripts/handlers stripped automaticallyconst reviewsEl = options.utils.$('.reviews')if (reviewsEl) options.utils.setHtml(reviewsEl, reviewsHtmlFromApi)// Raw: you are responsible for the contentconst heroEl = options.utils.$('.hero')if (heroEl) options.utils.setHtml(heroEl, trustedHtmlTemplate, { raw: true })Events
Helpers for attaching and removing event listeners. All listeners registered through options.utils are automatically removed when the variation is torn down.
utils.on(target, type, handler, opts?)
Attaches an event listener. Pass an EventTarget (an element, window) for a direct listener. Pass a CSS selector string instead, and the listener is delegated: one listener catches every matching element, including ones added to the page later. The handler receives the original event and, as a second argument, the matched element. Returns a { off() } handle.
// Direct listenerconst { off } = options.utils.on(window, 'scroll', () => { updateStickyHeader()})// Delegated listener: catches current and future .add-to-cart buttonsoptions.utils.on('.add-to-cart', 'click', (event, matchedEl) => { event.preventDefault() if (matchedEl instanceof HTMLElement) openUpsellModal(matchedEl.dataset.productId)})utils.onUrlChange(callback)
Fires callback(url) on every SPA route change (pushState, replaceState, and popstate). Useful for re-applying variation changes after navigation on single-page apps. Returns a { off() } handle.
options.utils.onUrlChange((url) => { if (url.includes('/checkout')) { applyCheckoutVariant() }})Data
Helpers for reading and writing browser storage, cookies, query parameters, and shared experiment state.
utils.cookie
A simple cookie API with three methods: cookie.get(name), cookie.set(name, value, opts?), and cookie.remove(name). The set options accept days, path, domain, sameSite, and secure.
const plan = options.utils.cookie.get('user_plan')if (plan === 'pro') { document.querySelector('.upgrade-banner')?.remove()}// Set a cookie that expires in 7 daysoptions.utils.cookie.set('exp_shown', '1', { days: 7 })utils.query
Read URL query parameters. query.get(name) returns the value of a single parameter or null. query.getAll() returns all parameters as a plain object.
const source = options.utils.query.get('utm_source')if (source === 'email') { document.querySelector('.email-hero')?.classList.add('active')}utils.storage.local and utils.storage.session
Type-safe wrappers around localStorage and sessionStorage. Each exposes get(key), set(key, value), and remove(key). Values are JSON-serialized automatically, and the generic parameter on get<T>(key) narrows the return type.
// Persist a flag across page loadsoptions.utils.storage.local.set('variantSeen', true)// Read it back (typed)const seen = options.utils.storage.local.get<boolean>('variantSeen')if (seen) skipIntroAnimation()utils.state
A lightweight in-memory key-value store, shared between the triggers.js and variation.js files of the same experiment. The trigger computes a value once, for example the matched element or a fetched object, and stores it. The variation reads it back instead of computing it again.
// In triggers.js: compute and storeoptions.utils.waitUntil<HTMLElement>('.product').then((el) => { options.utils.state.set('productEl', el) activate()})// In variation.js: retrieve what the trigger storedconst productEl = options.utils.state.get<HTMLElement>('productEl')if (productEl) { productEl.classList.add('variant-highlight')}Log
A scoped logger with three levels. log.debug() and log.info() emit only when the A vs B console is switched on: debug mode (?avsb_debug=1), a preview session, or dev mode. Real visitors never see them. log.warn() always emits. Messages are automatically prefixed so they are easy to filter in DevTools: the experiment's id inside trigger and variation code, or __project__ for top-level avsb.utils calls.
options.utils.log.debug('variation applied', { productId: '123' })options.utils.log.info('checkout form found')options.utils.log.warn('price element missing, skipping price change')Raw console.log calls in variation code are visible to every visitor who opens DevTools. utils.log.debug() and utils.log.info() are silenced in production, so your debugging output stays private. utils.log.warn() is intentionally always-on for genuinely unexpected conditions.