@qutuz-media/shared

v3.0.12

Published

Shared helpers for QRO runtime + experiments

Readme

@qutuz-media/shared

Shared helpers for the QRO runtime + experiments. Bundled INTO each consumer at build time — never shipped to the CDN as-is. No barrel export, ever (tree-shaking) — always import the subpath you need:

import { observeSelector } from "@qutuz-media/shared/spa/observe-selector";
import { setText, onClick } from "@qutuz-media/shared/dom";

Layout

shared/
├── (root)     # the standard kit — right on any site
├── spa/       # exists because virtual-DOM frameworks replace pages underneath you
└── utils/     # stateless parsing — no DOM writes, no cleanup, no events
  • Root is the default: dom, wait-for-element, wait-until, poll, the trackers, logger, and scope (the activation-tab engine everything depends on).
  • spa/ holds observe-selector, on-url-change, set-input-value, on-teardown — their behavior exists only because React/Vue/Angular re-render or control the page.
  • utils/ holds url (pageIs).

Where a helper lives follows why you'd reach for it, not what it's built on — root helpers may import from spa/ internally (dom → observe-selector).

Installation (public npm package)

Published to npm as @qutuz-media/shared. In experiments/ or QRO/:

npm install @qutuz-media/shared

Releasing

  1. npm version patch (or hand-edit the version — majors get npm version major)
  2. npm publish --access public
  3. In each consumer: plain npm install picks up patches/minors within ^. Major bumps require opt-in: npm install @qutuz-media/shared@latest — ^2.x never crosses into 3.x, by design.

A change here is NOT live until published.

The one rule

On an SPA, route change doesn't rebuild the page — your mutations and listeners leak onto the next route. So every helper here applies its change AND registers its undo on the current activation tab, which the runtime disposes on every route change (qutuz.teardown → all live tabs unwind, undos run in reverse order). You rarely write cleanup yourself.

Outside a tab (ad-hoc code only — experiments AND measurements both open tabs via their preludes) helpers fall back to the old behavior: undo waits on the teardown event, plus a one-time console warn. Degraded, never broken.

// the model in five lines:
//   experiment run() → activation() opens a tab
//   every helper registers its undo on the current tab
//   route change → teardown event → all tabs dispose (LIFO)
//   async/late fires re-enter their own tab via bindToCurrent — or die silently
//   outside a tab → undo waits on the teardown event (old wrapUndo behavior)

Raw primitives (listen, poll, ...) don't register — see the manual-cleanup list below.


Cheat sheet

| I want to... | Use | |---|---| | Change text/class/attr and not care about cleanup | setText/addClass/setAttr | | Inject markup/styles | injectHtml / injectCss | | React to clicks (element or selector) | onClick (tab-registered) | | Any event on an element | listen + register(listen(...)) | | Listen to a site's CustomEvent | onCustomEvent | | Re-apply on SPA re-render | spa/observe-selector | | Wait for one element to load, then act | wait-for-element | | Wait for a global/API to exist | wait-until | | Check which page I'm on | pageIs("/collections/all") from utils/url | | Time-on-page / engagement timing | register(trackTimerEvents(...)) | | Scroll depth | register(trackPageScroll(...)) | | Fill a React/Vue input | spa/set-input-value | | Register an undo on the current tab | register(undo) from scope | | Cleanup from tab-less code | spa/on-teardown (emergency hatch) |

Dev-loop gotcha: this package is inlined at build time. Changes here are invisible on the page until you rebuild experiments/ (and reload for a clean slate).


The manual-cleanup list — NOT tab-registered

Most of this package cleans up after itself. These do NOT — they hand you a stop/undo function and it's your job to wire it, or it leaks past the route change:

| Util | Returns | Leak if you forget | |---|---|---| | listen | undo | ghost event listeners stack on every route change | | spa/set-input-value | undo | (page state — usually harmless, but prefill survives nav) | | poll | stop | callback keeps firing forever on the new page | | track-timer-events | undo | un-fired timers keep counting on the NEW page's numbers | | track-page-scroll | undo | stale listeners keep announcing breakpoints after nav | | spa/on-url-change | stop | subscription survives (runtime-facing; experiments rarely touch it) |

The fix is always the same one-liner — hand it to register:

import { register } from "@qutuz-media/shared/scope";

register(listen(el, "mouseenter", showTooltip));    // dies with the tab
register(trackTimerEvents("homepage/engagement"));  // fresh per route

Why aren't they auto-registered like the dom helpers? Deliberate design boundary — these are lower-level primitives, and their call sites own the lifecycle decision. The auto-registered set is exactly: dom.js's mutation helpers + onClick + onCustomEvent + observe-selector. Everything else: wire it yourself.


scope — activation tabs (the cleanup engine)

import { activation, register, bindToCurrent } from "@qutuz-media/shared/scope";

One tab per experiment run; helpers register undos into the current tab; the teardown event disposes every live tab, undos running bottom-to-top (LIFO). Experiments get this for free — the generated prelude wraps run() in activation(). You touch this module only when building your own helpers.

Rules that matter:

  • R1 a top-level tab stays current after activation() returns — late sync code finds it
  • R2 disposers deregister from their tab (captured at registration), then run the undo once — single-shot, second call is a no-op
  • R3 outside a tab, undos fall back to the teardown EVENT (old wrapUndo behavior) plus a one-time warn
  • R4 undos are raw closures — never helper calls (they'd register mid-dispose)
  • R5 the teardown listener installs lazily — event name read after runtime boot
  • R6 late fires (post-await, MO callbacks, poll ticks) re-enter their own tab via bindToCurrent, or no-op if the tab died

register(undo) — shelf an undo on the current tab; returns a single-shot disposer (deregister + run). bindToCurrent(fn) — wrap a callback so late fires re-enter the current tab; pass-through when no tab is live.

// building your own tab-registered helper — the one-line pattern:
export function setStyle(el, prop, value) {
  const prev = el.style.getPropertyValue(prop);
  el.style.setProperty(prop, value);
  return register(() => el.style.setProperty(prop, prev));
}

Never hand-roll delays (setTimeout(() => setText(...), 3000)) — a bare timer firing after a route change can land its undo on the next run's tab, silently. Use waitForElement/waitUntil (both bound, both safe).


dom — the mutation vocabulary

import { setText, addClass, setAttr, injectHtml, injectCss,
         onCustomEvent, listen, onClick } from "@qutuz-media/shared/dom";

All of these except listen: apply → capture previous state → register undo on the current tab. You never think about cleanup. Manual disposal is the opt-out: every call returns a disposer that deregisters from the tab AND runs the undo:

const dispose = addClass(hero, "promo-badge");
// ... experiment paused mid-page by a killswitch:
dispose(); // class removed NOW, tab entry also gone

Basics

setText(heroHeading, "Golden Picks");                  // restores previous text on teardown
addClass(priceTag, "highlight");                       // one class per call
setAttr(cta, "aria-label", "Buy now");                 // restores previous value (or removes)
injectCss(".promo-badge { background: #9d00ff }");     // <style> added, self-removes
injectHtml(productCard, "afterbegin",                  // exact inserted nodes removed on teardown
  `<div class="promo-badge">Save 20%</div>`);

injectHtml positions: "beforebegin" | "afterbegin" | "beforeend" | "afterend" — relative to the target element (two of them insert outside it).

Events

listen is raw — compose it yourself; onClick/onCustomEvent register themselves:

import { listen, onClick } from "@qutuz-media/shared/dom";
import { register } from "@qutuz-media/shared/scope";

// raw: YOU own cleanup
register(listen(btn, "mouseenter", showTooltip));   // now it dies with the tab

// onClick composes internally — just call it:
onClick(buyBtn, () => trackEvent("cta_hover"));         // tab-registered out of the box
onClick(".late-mounted-card", handler);                 // selector form: waits for the
                                                        // element AND handles re-mounts

onCustomEvent — listen to any CustomEvent with detail unpacked:

onCustomEvent("product:viewed", (detail) => {
  trackEvent("pdp_view", { sku: detail.sku });
});

track-event / track-timer-events / track-page-scroll — the measurement layer

import { trackEvent } from "@qutuz-media/shared/track-event";
import { trackTimerEvents } from "@qutuz-media/shared/track-timer-events";
import { trackPageScroll } from "@qutuz-media/shared/track-page-scroll";

trackEvent(name, props?) — the ONLY way experiments fire PostHog events (never posthog.capture directly). Logs Metric fired: in dev/qa/log; capture suppressed in dev/qa (mode && mode !== "log" → no capture — test freely, data stays clean). Revenue: trackEvent("purchase", { value: 49 }).

trackTimerEvents(eventName, seconds?) — time-on-page. Fires eventName once per threshold (default [15, 30, 45]) with { elapsed_s } as a property. Filter in PostHog with elapsed_s >= 30. Call inside run() and hand it to register:

register(trackTimerEvents("homepage/engagement"));   // fresh measurement per route

trackPageScroll(eventName, breakpoints?) — scroll depth. Default [25, 50, 75, 100], fires with { scroll_percent }. Undo-returning, manual-clean (see the manual-cleanup list above):

register(trackPageScroll("homepage/scroll"));        // breakpoints reset on re-activation

Fires go through track-event → suppressed in dev/qa, visible in the TRACK logs. Debug lifecycle: Scroll producer started/stopped (dev+). Invalid breakpoints are logged and skipped, never silently dropped.


spa/observe-selector — survive SPA re-renders

import { observeSelector } from "@qutuz-media/shared/spa/observe-selector";

// Keep watching forever: fires on every (re)appearance of a match —
// React/Vue re-mounts, late loads, tab switches back
observeSelector(".add-to-cart", (el) => {
  setText(el, "Buy now");        // re-applies after every re-render, undoes on teardown
});

// One-shot
observeSelector("#hero-banner", setupHero, { once: true });

// With a deadline: stop + bail if it never showed up
observeSelector(".reviews", attachWidget, {
  timeout: 5000,
  onTimeout: () => logger.warn("reviews never mounted — skipping widget"),
});

Tab-registered by default: the observer dies with the tab on route change. One shared MutationObserver serves every selector on the page. Late fires are bound — a match appearing after the tab died is dropped silently.

Dedupe is per-element instance — a re-mounted node is a new fire; an unchanged node isn't.


wait-for-element — promise for the first match

import { waitForElement } from "@qutuz-media/shared/wait-for-element";

const btn = await waitForElement(".checkout-cta");
onClick(btn, handler);

// with a deadline — timeoutMs REJECTS, so catch it:
try {
  const el = await waitForElement(".newsletter-modal", { timeoutMs: 3000 });
} catch {
  return; // never appeared
}

A poll (waitUntil under the hood), not an observer — this is the "it hasn't rendered yet" instrument for any async page. For elements a framework rips out and re-renders, use spa/observe-selector (it re-fires per appearance; this resolves once). Poll ticks are bound — a tick after route change is dropped, not resolved.


spa/on-teardown — the emergency exit hatch

import { onTeardown } from "@qutuz-media/shared/spa/on-teardown";

Cleanup from tab-less code only (before/outside any activation). Everything inside a unit uses register:

onTeardown(() => vendorChart.destroy());

Returns a disposer that deregisters and runs the undo once. Custom event name as the optional second argument.


poll / wait-until — the waiting primitives

import { poll } from "@qutuz-media/shared/poll";
import { waitUntil } from "@qutuz-media/shared/wait-until";

// poll: callback every 20ms on a SHARED timer (N pollers at the same interval = 1 timer)
// NOT tab-registered — wire it or it keeps firing after route change:
register(poll(() => checkSomething()));

// wait-until: resolve the first time the predicate is truthy — the vendor-API gate.
// Poll ticks are bound — a tick after route change is dropped, not resolved.
await waitUntil(() => window.stripe !== undefined, { timeoutMs: 5000 });
initStripeCheckout();

wait-until checks synchronously once first (fast path), then polls. Optional timeoutMs rejects with an Error.


spa/set-input-value — React/Vue-controlled inputs

import { setInputValue } from "@qutuz-media/shared/spa/set-input-value";

el.value = x on a controlled input does NOTHING — React owns the value and overwrites it. This uses the native-setter + synthetic-event dispatch trick so the framework sees the change:

setInputValue(emailField, "[email protected]");   // React actually updates

Returns an undo that restores the previous value the same safe way. eventType option: "input" (default, React/most) or "change" (legacy jQuery listeners).


spa/on-url-change — the SPA signal (runtime-facing)

import { onUrlChange } from "@qutuz-media/shared/spa/on-url-change";

The History-patch primitive: patches pushState/replaceState, listens popstate/hashchange, funnels all into one callback. This is what the runtime's spa.js consumes to drive teardown/rescan — experiments rarely need it directly (observe-selector already handles re-renders). Returns stop.


utils/url — page matching

import { pageIs } from "@qutuz-media/shared/utils/url";

if (pageIs("/collections/all")) {
  trackEvent("navigation/all-plp-viewed-event");
}

Path equality ignoring trailing slashes; query strings never reach pathname, so ?variant=... can't break the match. startsWith would — "/collections/all" also matches /collections/all-stars.


logger — infra

import { createLogger } from "@qutuz-media/shared/logger";

const logger = createLogger("MY EXPERIMENT");
logger("applied");          // level 1: visible in dev/qa/log modes
logger.debug("state", obj); // level 2: qa/log modes only
logger.warn(...);           // level 1
logger.error(...);          // ALWAYS logs, even in prod

Levels derive from the QRO mode (window.QRO.mode, the SSOT — never re-read the URL): prod = errors only; dev = +logs; qa/log = +debug. Anything that logs before window.QRO is written logs at level 0 (silently) — the seat must exist first.

Tag colors = module identity. Each runtime module owns a hue, so a noisy console triages by color-scan — find your module's color, read only those lines:

| Tag | Hue | |---|---| | [QRO] — runtime core | purple | | [QRO LOADER] — experiments flowing | green | | [QRO MODE] — the dial | amber | | [QRO SHARED] — package code on page | cyan | | [QRO TRACK] — metrics | pink | | [QRO SPA] — route bridge | lime |

| Severity | Hue | |---|---| | [warn] | yellow | | [error] | red |

The severity column is the rule: info/debug → module color · warn → always yellow · error → always red — "problem" keeps one color no matter who said it. Unknown tags (e.g. your experiment's own tag) fall back to brand purple; add a hue to TAG_COLORS in logger.js when a module earns one.

Message convention: KIND: detail (Resolved: …, Excluded: …, Loaded: …, QA override — …). Tag answers who, kind answers what happened — keep both stable and greppable.

Logging in dev/qa won't pollute data: track-event no-ops its capture in any mode except log/prod (see track-event.js) — click your test button 100x freely.


Async styles: chain vs await (both first-class; house pattern TBD)

Author code may use either; run() stays a sync function run() declaration always (the build requires it). The scaffold examples teach chain style; pick per task and let muscle memory decide the house pattern over time:

// CHAIN (fire-and-forget — the CRO-Metrics idiom, shortest for one wait):
waitForElement(".cta").then((el) => {
  setText(el, "Buy now");
});

// AWAIT (sequential waits, conditional flows — run's callee goes async, run() stays sync):
export async function init() {
  const a = await waitForElement(".step-1");
  if (!a.dataset.ready) return;
  const b = await waitForElement(".step-2");
  injectHtml(b, "beforeend", html);
}
// switch: case "v1": void v1(); break;   // void = "returns a promise, ignored on purpose"

Rules that hold in both styles: run() is never async; never hand-roll setTimeout delays (a bare timer after route change lands on the next run's tab — use waitForElement/waitUntil, both bound); a hung waitForElement without timeoutMs never settles (teardown kills it silently — add timeoutMs for diagnostics).