@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, andscope(the activation-tab engine everything depends on). spa/holdsobserve-selector,on-url-change,set-input-value,on-teardown— their behavior exists only because React/Vue/Angular re-render or control the page.utils/holdsurl(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/sharedReleasing
npm version patch(or hand-edit the version — majors getnpm version major)npm publish --access public- In each consumer: plain
npm installpicks up patches/minors within^. Major bumps require opt-in:npm install @qutuz-media/shared@latest—^2.xnever crosses into3.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 routeWhy 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 goneBasics
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-mountsonCustomEvent — 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 routetrackPageScroll(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-activationFires 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 updatesReturns 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 prodLevels 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 | |
|
[QRO LOADER] — experiments flowing | |
|
[QRO MODE] — the dial | |
|
[QRO SHARED] — package code on page | |
|
[QRO TRACK] — metrics | |
|
[QRO SPA] — route bridge | |
| Severity | Hue |
|---|---|
| [warn] | |
|
[error] | |
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).