> ## Documentation Index
> Fetch the complete documentation index at: https://docs.abtestly.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Single-page apps

> How ABTestly works on apps with client-side routing — the lifecycle, the helpers, the gotchas, and the patterns that hold up in production.

If your site is built with **React, Vue, Next.js, Nuxt, SvelteKit, Astro**,
or any framework that swaps the page in JavaScript instead of doing a full
browser navigation, you have a single-page app (SPA). ABTestly supports
SPAs natively — the snippet watches for in-app route changes and re-runs
your experiments **without a page reload**.

This page is the **definitive reference**. It covers what to turn on, the
lifecycle, the three developer helpers (`onApply` / `onCleanup` / `waitFor`),
the canonical variant template, every common pitfall, and the guarantees we
make so your results stay trustworthy.

<Note>
  **TL;DR for impatient devs.** Turn on **SPA mode** in your site settings.
  In a variant's **JavaScript** field, wrap your DOM work in
  `abtestly.onApply(ABTESTLY_EXP_KEY, () => { ... })`. If you need to undo anything
  (listeners, body classes, third-party widgets), pair it with
  `abtestly.onCleanup(ABTESTLY_EXP_KEY, () => { ... })`. If the element you're editing
  renders asynchronously, wrap the inside of `onApply` in
  `abtestly.waitFor('.your-selector', (el) => { ... })`. The full canonical
  template is just one click in the variant editor (**Insert SPA template**)
  — it's what most teams paste and adapt.
</Note>

***

## 1. Enable SPA mode

SPA mode is a **per-site setting**. In your dashboard:

<Steps>
  <Step title="Open the site">
    Sites → your site → **SPA** tab.
  </Step>

  <Step title="Turn on Enable SPA support">
    Toggling republishes your site config so the change reaches visitors
    on the next pageview.
  </Step>
</Steps>

When SPA mode is **on**, the snippet:

* Wraps `history.pushState` and `history.replaceState`.
* Listens for `popstate` (back/forward) and `hashchange` (hash routers).
* Runs the [SPA lifecycle](#2-the-spa-lifecycle) on every detected
  route change.

When SPA mode is **off** (the default), the snippet behaves exactly like
it does on a traditional multi-page site — **zero overhead, zero behavior
change**. There's no penalty for leaving it off on a non-SPA.

***

## 2. The SPA lifecycle

This is the single mental model the rest of the page builds on. Read it
once and the helpers below will make sense.

### Initial page load (any site)

1. The snippet boots, evaluates targeting and audiences for each experiment.
2. For every eligible experiment:
   1. A bucket is computed (or read from cache if the visitor's been here).
   2. An **impression beacon** fires.
   3. The variant's **CSS** is injected into `<head>` (tagged
      `<style data-abtestly-exp="<id>">` so we can find it later).
   4. The variant's **JavaScript** runs — once. This is where your
      `onApply` / `onCleanup` registrations land.
   5. Any registered **`onApply` callbacks** fire.

### In-app route change (SPA mode only)

When the visitor navigates client-side — clicks a `<Link>`, your router
calls `pushState`, the back button fires `popstate`, the hash changes —
ABTestly runs this sequence:

1. **Cleanup first.** Every `onCleanup` callback for every experiment that
   was active on the **previous** route fires. This includes experiments
   that are still active on the new route — so they re-apply cleanly with
   no double-bound listeners or duplicated styles.
2. **CSS auto-cleanup.** For experiments that are *truly* deactivated
   (eligible before, not eligible now), we remove their
   `<style data-abtestly-exp>` tag from `<head>` automatically.
3. **Re-evaluate targeting** for every experiment against the new URL.
   Mid-session bucketing kicks in for visitors who become eligible for the
   first time on this route (see [Mid-session bucketing](#6-mid-session-bucketing)).
4. **Impressions fire** for every eligible experiment (one batched beacon
   to `/e` per route change, plus a GA4 `experience_impression` push if
   you have GA4 enabled). This pass runs **before** any variant DOM work
   so a broken variant on one arm can never under-count impressions on
   that arm — see [Trust & isolation](#11-trust-and-isolation) for the SRM
   rationale.
5. **CSS re-inject + `onApply` re-fire** for every eligible experiment.
   Variant **JavaScript does NOT re-run** — only your registered `onApply`
   callbacks do. (Re-running the JS would stack callback registrations
   on every navigation.)
6. **`page_visit` goals** re-evaluate against the new URL — so a goal
   pointing at `/thank-you` fires on a soft nav to `/thank-you`. Already-
   fired goals dedupe per session. Listener goals (click, form-submit,
   etc.) are NOT re-attached — their handlers are still live.
7. **`abtestly:routechange`** CustomEvent dispatches on `window` with
   the list of active + deactivated experiments — see
   [The `abtestly:routechange` event](#10-the-abtestlyroutechange-event).

That's it. If you remember **"cleanup → impression → apply"**, you
understand the SPA loop.

***

## 3. Writing variant code — the canonical pattern

This is the shape almost every SPA variant should take. Click **Insert
SPA template** in the variant editor to paste it.

```js theme={null}
// ABTESTLY_EXP_KEY and ABTESTLY_VARIANT_KEY are auto-bound — see section 4.
// Nothing to paste.

// Per-experiment marker attribute. MUST be unique per experiment —
// using a shared name like `data-abt-done` collides when two tests
// target the same element (one cleanup wipes the other's guard).
const DONE_ATTR = 'data-abtestly-applied-' + ABTESTLY_EXP_KEY;

window.abtestly.onApply(ABTESTLY_EXP_KEY, () => {
  window.abtestly.waitFor('.product-info', (root) => {
    // 1. Idempotency guard — onApply can fire twice on rapid navigation.
    //    Per-experiment attribute so parallel tests don't collide.
    if (root.getAttribute(DONE_ATTR) === ABTESTLY_VARIANT_KEY) return;
    root.setAttribute(DONE_ATTR, ABTESTLY_VARIANT_KEY);

    // 2. Make your changes. Tag anything you inject so cleanup can find it.
    const banner = document.createElement('div');
    banner.className = 'abt-promo-banner';
    banner.setAttribute('data-abt-exp', ABTESTLY_EXP_KEY);
    banner.textContent = 'New promo!';
    root.prepend(banner);

    // 3. Track listeners by reference so cleanup can unbind them.
    const onBannerClick = () => { /* ... */ };
    banner.addEventListener('click', onBannerClick);

    // 4. Stash everything cleanup will need.
    window[`__abt_state_${ABTESTLY_EXP_KEY}`] = {
      root,
      listeners: [{ el: banner, type: 'click', fn: onBannerClick }],
    };
  }, 15000);   // bump the timeout for slow PDPs
});

window.abtestly.onCleanup(ABTESTLY_EXP_KEY, () => {
  const state = window[`__abt_state_${ABTESTLY_EXP_KEY}`];
  if (!state) return;

  // Remove everything tagged with this exp's ID — one query, no looping.
  document
    .querySelectorAll(`[data-abt-exp="${ABTESTLY_EXP_KEY}"]`)
    .forEach((n) => n.remove());

  // Unbind listeners.
  state.listeners.forEach(({ el, type, fn }) => {
    try { el.removeEventListener(type, fn); } catch (e) {}
  });

  // Clear the idempotency flag so a return-visit re-applies cleanly.
  // Per-experiment attribute, so removing it never affects another test.
  if (state.root) state.root.removeAttribute(DONE_ATTR);

  delete window[`__abt_state_${ABTESTLY_EXP_KEY}`];
});
```

The five things this template solves — every one of them is a real
production bug we've seen:

| # | Pattern                                                                                   | Why                                                                                                    |
| - | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| 1 | `waitFor` against a **page-specific** selector                                            | Stops the variant firing on a different route where a stale global says "ready"                        |
| 2 | Per-experiment marker attribute (`data-abtestly-applied-scr003`) as the idempotency guard | Stops a fast nav firing `onApply` twice — and doesn't collide with a parallel test on the same element |
| 3 | `data-abt-exp` tag on injected DOM                                                        | One `querySelectorAll` removes everything                                                              |
| 4 | Listener reference tracked in `window[__abt_state_*]`                                     | Lets `onCleanup` actually unbind them                                                                  |
| 5 | `root.removeAttribute(DONE_ATTR)` in cleanup                                              | Lets a return visit re-apply                                                                           |

***

## 4. The `ABTESTLY_EXP_KEY` / `ABTESTLY_VARIANT_KEY` magic locals

Inside variant JavaScript, the snippet runs your code as if you'd written:

```js theme={null}
function (ABTESTLY_EXP_KEY, ABTESTLY_VARIANT_KEY) {
  // your variant code
}
```

…and calls it with this experiment's code and the active variant key. So
inside variant JS:

* `ABTESTLY_EXP_KEY` is this experiment's short, readable code (e.g. `"scr003"`).
  It's unique within your site and fixed for the life of the experiment.
* `ABTESTLY_VARIANT_KEY` is the key of the variant currently being applied
  (`"control"`, `"v1"`, etc.).

Use `ABTESTLY_EXP_KEY` anywhere you need to identify the experiment — including
`onApply`, `onCleanup`, and `dev.apply`, which all accept the code.

<Note>
  The experiment's raw UUID is still bound as `ABTESTLY_EXP_ID` (along with the
  older `EXP_ID` / `VARIANT_KEY` aliases) so variants written before codes existed
  keep working unchanged. New code should prefer `ABTESTLY_EXP_KEY` — it's far
  shorter, and it stays readable in the DOM and in your logs.
</Note>

You **never have to paste an identifier into variant code**. Use the bound
locals — they're cleaner to read and immune to copy-paste errors.

<Warning>
  **Don't redeclare them.** Writing `const ABTESTLY_EXP_KEY = ...` (or `let` / `var`)
  inside variant JS throws `SyntaxError: Identifier 'ABTESTLY_EXP_KEY' has already
    been declared`. The whole variant then fails to apply silently — you only
  see the error in our internal error beacon. The dashboard editor lints
  for this and flags it before you save. The same applies to `ABTESTLY_VARIANT_KEY`
  and `ABTESTLY_EXP_ID`.
</Warning>

The variant editor's **Bound at runtime** strip shows this experiment's code
and the active variant key, each with a copy button, so you can grab them
without leaving the editor.

<Note>
  **Legacy aliases.** Variants written before the `ABTESTLY_` prefix existed used
  the unprefixed `EXP_ID` and `VARIANT_KEY`. Those are still bound at runtime, so
  older variants keep working unchanged — no migration needed. New code should use
  the prefixed names: they can't collide with a global on the host page.
</Note>

***

## 5. The three helpers

All three live on `window.abtestly` (and `window.__abtestly` — see
[Namespace](#namespace-window-abtestly-vs-window-abtestly)).

### `abtestly.onApply(experimentId, fn)`

Registers a callback that runs **every time the experiment becomes active
on the current route** — on initial page load and again on every SPA
navigation back to a tested page within the session.

```js theme={null}
abtestly.onApply(ABTESTLY_EXP_KEY, () => {
  document.querySelector('.cta').textContent = 'Start free trial';
});
```

**Three behaviors worth knowing:**

* **Multiple registrations stack.** Calling `onApply(ABTESTLY_EXP_KEY, …)` twice
  registers two callbacks; both fire on every activation, in order.
* **Late registrations auto-fire.** If you register an `onApply` callback
  *after* the experiment is already active (devtools console, a
  `<script>` that loads after the snippet, a browser-extension
  userscript), the callback fires **once immediately**, then again on
  every subsequent route change. So console-based iteration works as
  you'd expect.
* **One callback per experiment per route.** A single registered callback
  fires exactly once per route activation, never twice per route.

### `abtestly.onCleanup(experimentId, fn)`

Registers a callback that runs **when the visitor leaves a tested route**
and **just before a re-apply** (so changes never stack).

```js theme={null}
abtestly.onCleanup(ABTESTLY_EXP_KEY, () => {
  document.body.classList.remove('variant-b');
});
```

**What you need to clean up:**

| Change                                      | Who cleans it                                                                                            |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Variant **CSS** from the editor's CSS field | **ABTestly** (auto, via `<style data-abtestly-exp>` removal)                                             |
| `document.createElement(...)` you injected  | **You** — see the tag-and-strip pattern in [the template](#3-writing-variant-code-the-canonical-pattern) |
| `addEventListener` you added                | **You** — store the handler reference, call `removeEventListener`                                        |
| Body / element class names you added        | **You** — `classList.remove(...)`                                                                        |
| Third-party widgets you initialized         | **You** — call their teardown / `.destroy()`                                                             |
| `setInterval` / `setTimeout` you started    | **You** — `clearInterval` / `clearTimeout`                                                               |

Anything you can't safely auto-undo is yours. Anything we **know** we
own (the editor's CSS field) is ours.

### `abtestly.waitFor(target, callback, timeoutMs?)`

SPA frameworks render asynchronously — when a route changes, the new
page's DOM and data may not exist for a few hundred milliseconds.
`waitFor` polls every **50 ms**, fires your callback **once** when the
target becomes truthy, and gives up silently after `timeoutMs`
(default **5000**). Your target lookup, the predicate, and the callback
are all wrapped in try/catch so a mistake in variant code can't break
the host page.

#### Signature — always three arguments

```js theme={null}
abtestly.waitFor(target, callback, timeoutMs);
```

<Warning>
  **Common mistake — 4 arguments silently breaks.** `waitFor` takes
  **three** args, not four. If you want to wait on multiple conditions,
  pass them in an **array** as the first argument (see [Array form](#array-form-multiple-conditions)).

  Passing four separate args binds the second function to `callback` and
  puts your real callback in the `timeoutMs` slot — your code never runs,
  and the timeout check evaluates against `NaN` so it never gives up
  either.

  ```js theme={null}
  // ❌ WRONG — second function is treated as the callback, the real
  //    callback is silently dropped.
  abtestly.waitFor(
    () => isPdp(),
    () => document.querySelector('.product-card'),
    (root) => { /* never runs */ },
    15000
  );

  // ✅ RIGHT — array form for multiple conditions.
  abtestly.waitFor(
    [() => isPdp(), '.product-card'],
    (results) => { /* runs when both are truthy */ },
    15000
  );
  ```
</Warning>

#### Three forms for `target`

| Form                                            | Example                        | What `callback` receives                                  |
| ----------------------------------------------- | ------------------------------ | --------------------------------------------------------- |
| **CSS selector** (string)                       | `'.product-price'`             | The matched `Element`                                     |
| **Predicate** (function returning truthy/falsy) | `() => window.appStore?.ready` | `null`                                                    |
| **Array** (mixed selectors + predicates)        | `['.foo', () => window.ready]` | `Array<Element \| null>` — one entry per target, in order |

The array form waits for **all** targets to become truthy (AND
semantics) before firing.

#### Selector form

```js theme={null}
abtestly.onApply(ABTESTLY_EXP_KEY, () => {
  abtestly.waitFor('.product-price', (el) => {
    el.textContent = '$79/mo';
  });
});
```

#### Predicate form

The callback gets `null` — predicates don't return an element. If you
used the predicate to wait on an element, query for it inside the
callback:

```js theme={null}
abtestly.onApply(ABTESTLY_EXP_KEY, () => {
  abtestly.waitFor(
    () => window.appStore?.ready,
    () => {
      // No `el` arg — re-query for the DOM you need.
      document.querySelector('.cta').textContent = 'Start free trial';
    },
    8000
  );
});
```

#### Array form (multiple conditions)

Use when you want to gate on **both** a page-state predicate AND a
DOM-element presence:

```js theme={null}
const DONE_ATTR = 'data-abtestly-applied-' + ABTESTLY_EXP_KEY;

abtestly.onApply(ABTESTLY_EXP_KEY, () => {
  abtestly.waitFor(
    [
      () => isPdp() || isPlp(),                              // page check
      '[data-qaid="product-card"], [data-qaid="product-price"]', // DOM
    ],
    (results) => {
      // results[0] → predicate slot → null
      // results[1] → selector slot → matched Element
      const root = results[1];
      if (root.getAttribute(DONE_ATTR) === ABTESTLY_VARIANT_KEY) return;
      root.setAttribute(DONE_ATTR, ABTESTLY_VARIANT_KEY);
      // ... your variant code, using `root`
    },
    15000
  );
});
```

<Note>
  **Selector form + `if` guard** is often cleaner than the array form for
  "page check AND find an element." Pick whichever reads more clearly:

  ```js theme={null}
  abtestly.waitFor('[data-qaid="product-card"]', (root) => {
    if (!isPdp() && !isPlp()) return;  // page-check guard
    // ... your variant code
  });
  ```
</Note>

<Tip>
  **Prefer `waitFor` over your own `setInterval`/`setTimeout`.** Hand-rolled
  waits are the most common source of SPA data-quality bugs — a missing
  double-fire guard double-applies a variant, a missing timeout leaks a
  timer, an unguarded callback throws and breaks the page. `waitFor`
  handles all three.
</Tip>

***

## 6. Mid-session bucketing

A visitor enters a test the first time they reach a page the test
targets — whether they **landed** on that page directly, or **navigated**
to it from somewhere else inside your app.

That second case is the SPA part. If a visitor lands on `/` and clicks
through to `/pricing` (where your test runs), they enter the test at
`/pricing` — not at `/`. ABTestly catches the in-app navigation,
re-checks targeting, and counts the visitor once they reach a page
where the test applies.

<Note>
  **Why this matters statistically.** "Landed there" and "navigated there
  inside your app" are the same person making the same trip — just via
  different front doors. Counting only direct landings misses roughly
  half of traffic on most SPAs and skews results toward whichever variant
  sits at the entry-point of the funnel. The standard practice for
  triggered experiments — count visitors when they first reach the tested
  page — keeps results comparable across pages.
</Note>

A visitor is counted at most **once per experiment** on the dashboard,
regardless of how many times they navigate back to the tested page.
The variant they get is **deterministic and sticky**: mid-session
bucketing uses the same calculation as initial-load bucketing, so they
get the *same* variant either way. That changes *who's included*, never
the *split ratio* — which keeps your test statistically sound (it's
SRM-safe on the assignment side).

***

## 7. Targeting in a SPA

URL-based targeting (URL contains / starts with / regex / hostname /
query string — see [Targeting](./targeting-overview)) works the same on
SPAs as on traditional sites. ABTestly **re-checks targeting on every
in-app route change**, so a test that targets `/pricing` runs on a
visitor who clicks from `/` to `/pricing`.

If a visitor leaves a tested page for a non-tested route, the cleanup
flow ([section 8](#8-automatic-cleanup)) runs.

<Note>
  **One caveat — audience conditions don't re-evaluate.** Audience
  attributes (device, country, cookie, custom attribute) are evaluated
  **once — the first time the visitor becomes eligible for the
  experiment** — and that result sticks for the session. URL and location
  targeting re-check on every route change; audience does not.

  This almost never matters because audience signals are stable within a
  session (device and country don't change). The one case worth knowing:
  if your audience depends on a **cookie that gets set mid-session** (e.g.
  a login cookie written on a later route), ABTestly won't pick up the
  change until the next full page load. Target on the URL the change
  leads to instead, or reach out and we'll help.
</Note>

***

## 8. Automatic cleanup

When a visitor leaves a tested route, ABTestly **automatically removes**
any CSS your variant injected through the editor's **CSS** field. Each
block is tagged internally as `<style data-abtestly-exp="<expId>">`, so
we know exactly what we own.

We **don't** auto-undo arbitrary JavaScript — body classes, event
listeners, third-party widgets. Those live in `onCleanup`. We can only
safely auto-clean what we know we own.

On every route change, cleanup runs **before** re-apply — for *all*
previously-active experiments, including those still active on the new
route. That ordering guarantees your changes never stack (no
double-bound listeners, no duplicated styles).

***

## 9. Goals in a SPA

Two behaviors to know:

* **`page_visit` goals re-evaluate** on every route change. A goal
  pointing at `/thank-you` fires when the visitor soft-navigates there.
  Already-fired goals dedupe per session, so a back-and-forth visit to
  `/thank-you` counts once.
* **Listener-based goals** (click, form-submit, scroll) are **not
  re-attached** on route change. Their handlers were installed at
  initial load and stay live — they share the same per-session dedup
  Set as the page-visit goals.

If your "thank you" page is reached via SPA navigation, `page_visit`
will catch it. If your goal is "click the upgrade button," install it
once and it tracks across the session.

***

## 10. The `abtestly:routechange` event

For advanced cases, ABTestly dispatches a `CustomEvent` on `window`
**after** each in-app route change it processes:

```js theme={null}
window.addEventListener('abtestly:routechange', (e) => {
  console.log(e.detail);
  // {
  //   url: 'https://yoursite.com/pricing',
  //   activeExperiments: ['exp_abc123'],       // active on the NEW route
  //   deactivatedExperiments: ['exp_def456'],  // were active, now aren't
  // }
});
```

Dispatched **last** in the lifecycle, so by the time your listener
runs, every `onCleanup` and `onApply` has already fired. Use this when
you need to coordinate something outside a single experiment's variant
code — notifying your own analytics that an experiment context
changed, syncing a state store, etc.

***

## 11. Trust & isolation

SPAs make A/B testing harder to get right — async rendering, repeated
page views, variant code that can throw. Here's what ABTestly does so
your numbers stay sound. You don't configure any of it.

* **Impressions count before any variant code runs.** On each route
  change every eligible experiment's impression is recorded — to the
  ABTestly dashboard AND to your GA4 export — in a first pass, **before**
  applying any variant CSS or JavaScript. So if one variant's code
  throws and another's doesn't, both arms still record their impression.
  This is how we prevent [sample ratio mismatch](./srm) caused by a
  broken variant silently under-counting one arm.
* **Each experiment is isolated.** Every variant's JavaScript, every
  `onApply` / `onCleanup` / `waitFor` callback, and our own route
  handler all run inside their own try/catch. A throw in one experiment
  can't break the page, another experiment, or ABTestly's tracking.
* **Bucketing is deterministic and sticky.** A visitor gets the same
  variant whether they land on the tested page or navigate to it
  mid-session, and the same variant every time they return.
* **Impressions dedupe per route, not per session.** A visitor who
  opens `/pricing`, leaves, and comes back records an impression each
  time they arrive (matching how GA4 counts page views). The dashboard
  still resolves that to **one** distinct participant. Event-level
  exports and participant-level results stay internally consistent.
* **Variant errors are reported.** If your variant's JavaScript throws
  on a customer's browser — on apply, cleanup, or a route change — the
  snippet reports it back to us (first occurrence per session, so we
  don't spam visitors' connections). This is how we catch a variant
  that works in your testing but breaks on some real-world page,
  before it quietly corrupts a test.

### How we verify all of this

The SPA engine ships with an automated test suite covering route
detection across `pushState` / `replaceState` / `popstate` / `hashchange`,
the cleanup-before-apply ordering, the impression-before-apply rule,
the per-route dedup, the sticky-bucketing determinism, the `waitFor`
helper's timeout + single-fire guarantees, the error reporter, and a
non-regression check that SPA-mode-off behaves identically to a
traditional site. Every release runs the full suite before it reaches
your snippet.

***

## 12. Local development & QA

### Namespace: `window.abtestly` vs `window.__abtestly`

The snippet always installs the helpers under `window.__abtestly` (the
underscored, conflict-proof name). For ergonomics it ALSO sets
`window.abtestly` as an alias **if that slot is free**.

* **Most sites:** use `window.abtestly.onApply(...)` — shorter to type,
  matches every example we publish, and resolves to the same object.
* **Sites that already use `window.abtestly` for something else:**
  the alias is skipped; use `window.__abtestly.onApply(...)`.

Variant code generated by the editor uses `window.abtestly` because the
alias is set the moment the snippet runs.

### `abtestly.dev.apply(ABTESTLY_EXP_KEY)` — local QA hook

When you're iterating on variant code on your own machine and don't
want to open a preview link every time, paste this in the devtools
console (or in a browser-extension userscript) to activate the
experiment for the current session:

```js theme={null}
window.abtestly.dev.apply('scr003');
```

Pass the experiment's code (shown in the editor) — the raw UUID also still works.

It does exactly two things:

1. Marks the experiment as currently active for this session.
2. Synchronously fires any `onApply` callbacks already registered for it
   — and any **later** `onApply(ABTESTLY_EXP_KEY, fn)` calls auto-fire too.

After that, the SPA lifecycle runs normally for that experiment: an
in-app navigation fires `onCleanup` on the way out and `onApply` again
on the way in. No reload, no preview link.

<Warning>
  **Console / userscript only.** Don't put `abtestly.dev.*` calls in the
  variant **JavaScript** field. Doing so makes the apply pipeline fire
  twice per page load (your variant calls it, then the runtime's normal
  initial-apply pass runs again) and your `onApply` callback ends up
  double-firing on every visitor. The dashboard's variant editor flags
  this with a yellow warning if you forget.
</Warning>

### Preview links on SPAs

Preview links (see [Preview links](./preview)) work natively on SPAs —
the lifecycle runs the same way it would for real visitors. Navigate
through your app with a preview link active and your variant follows
you across routes: `onCleanup` fires on the way out of a tested page,
`onApply` fires on the way in to the next eligible one. No need to
refresh to make the variant re-appear.

### Prefer to roll your own?

`onApply` / `onCleanup` / `waitFor` are convenience APIs, not
requirements. If you'd rather drive SPA application from your own code
— your framework's router events, a `MutationObserver`, your own
`pushState` wrapper — you have two clean exits:

* **Listen to [`abtestly:routechange`](#10-the-abtestlyroutechange-event)**
  for a per-route signal you can hook anything into.
* **Skip the helpers entirely** — write plain JS in the variant code
  field and wire up your own listeners. ABTestly's snippet doesn't
  require `onApply`; it's just a convenience for the common case.

The tradeoff: rolling your own means you handle cleanup, idempotency,
and timing yourself. The helpers exist because those three are where
SPA tests most often go wrong — but if you have a pattern that works
for your stack, use it.

***

## 13. Common pitfalls

The mistakes we see most often. Avoid these and your SPA tests will
behave.

<AccordionGroup>
  <Accordion title="Stale-positive `waitFor` predicates (THE classic SPA bug)">
    A predicate like `() => window.__NEXT_DATA__ !== undefined` is truthy
    on the listing page AND the product page — but the product DOM doesn't
    exist on the listing page. If your visitor lands on `/listing`, the
    predicate fires immediately (`true` everywhere), your variant tries to
    query a `/product`-only element, gets `null`, and breaks.

    **Fix:** wait on something that's **only** present on the page you're
    targeting — a PDP-specific element, a body class your app sets on
    that route, a query against a router-specific data attribute.

    ```js theme={null}
    // ❌ Fires on every page where this global exists
    abtestly.waitFor(() => window.__NEXT_DATA__, ...);

    // ✅ Fires only on the page where this element renders
    abtestly.waitFor('.product-detail__price', ...);
    ```
  </Accordion>

  <Accordion title="Calling `waitFor` with 4 separate arguments">
    `waitFor(target, callback, timeoutMs)` is a **3-argument** function.
    Passing four args binds the second function to `callback` and your
    real callback ends up in the `timeoutMs` slot — your code never runs,
    and the timeout check evaluates against `NaN` so the silent-give-up
    never fires either. Use the **array form** for multi-condition waits.

    See the [warning under `waitFor`](#signature-always-three-arguments).
  </Accordion>

  <Accordion title="Redeclaring `ABTESTLY_EXP_KEY` or `ABTESTLY_VARIANT_KEY`">
    These are bound by the snippet as function parameters. Writing
    `const ABTESTLY_EXP_KEY = ...` (or `let` / `var`) inside variant JS throws
    `SyntaxError: Identifier 'ABTESTLY_EXP_KEY' has already been declared` and
    the variant silently fails to apply. The dashboard editor lints
    for this — heed the warning.
  </Accordion>

  <Accordion title="Calling `abtestly.dev.apply` from variant code">
    `dev.apply` is a **local-QA** hook for the devtools console or
    userscripts. Put it in variant JS and the apply pipeline fires
    twice per page load — once because your variant called it, once
    because the runtime's normal initial-apply pass runs. Your `onApply`
    callbacks then double-fire on every visitor. The editor flags this.
  </Accordion>

  <Accordion title="DOM changes in a one-time `<script>` block instead of `onApply`">
    Code at the top level of the variant JS field runs **once** at the
    moment the variant injects — not on subsequent SPA navigations. If
    the visitor leaves the tested route and comes back, your changes
    don't re-apply.

    Put DOM mutations inside `onApply` so they re-fire on every
    activation.
  </Accordion>

  <Accordion title="Forgetting `onCleanup` for listeners">
    Each SPA navigation can re-fire `onApply` (when the visitor returns
    to a tested route). Without `onCleanup` unbinding your listeners,
    you stack a fresh handler on every activation. After a few
    navigations the listener fires N times per event. Always pair
    `onApply` listener binds with `onCleanup` unbinds.

    The [canonical template](#3-writing-variant-code-the-canonical-pattern)
    does this for you.
  </Accordion>

  <Accordion title="Forgetting the idempotency guard">
    Some SPA frameworks fire `pushState` two or three times during a
    single navigation. Without a guard like
    `if (root.getAttribute(DONE_ATTR) === ABTESTLY_VARIANT_KEY) return;`,
    your `onApply` body runs once per fire and your DOM mutations stack.
    The template includes this pattern.
  </Accordion>

  <Accordion title="Sharing one marker attribute across multiple parallel experiments">
    If two experiments both write `root.dataset.abtDone` (or any other
    shared attribute) on the same element, they collide:

    1. Experiment A sets `data-abt-done="v1"`.
    2. Experiment B reads it, sees `"v1" !== "v2"`, applies its own changes.
    3. Experiment A's cleanup runs and `delete`s the attribute — wiping
       B's guard too.
    4. On the next route activation, B re-applies on top of itself.

    Use a **per-experiment attribute name** so the two never share state.
    The canonical template builds it as:

    ```js theme={null}
    const DONE_ATTR = 'data-abtestly-applied-' + ABTESTLY_EXP_KEY;

    if (root.getAttribute(DONE_ATTR) === ABTESTLY_VARIANT_KEY) return;
    root.setAttribute(DONE_ATTR, ABTESTLY_VARIANT_KEY);
    // …in cleanup:
    root.removeAttribute(DONE_ATTR);
    ```

    Each experiment ends up with its own attribute (`data-abtestly-applied-scr003`,
    `data-abtestly-applied-scr007`) and they can target the same
    element without stepping on each other.
  </Accordion>

  <Accordion title="Targeting on an audience attribute that changes mid-session">
    Audience evaluation is session-sticky (see the
    [section 7 caveat](#7-targeting-in-a-spa)). If you target on a
    cookie / attribute that gets set after the visitor first becomes
    eligible — most commonly a login cookie written on a later route —
    the change doesn't propagate until the next full page load. Target
    on the URL the change leads to instead.
  </Accordion>
</AccordionGroup>

***

## 14. Why does GA4 show more impressions than my ABTestly dashboard?

The most common question SPA users ask. Short answer: **they measure
different things, and that's expected.**

* GA4's `experience_impression` is an **exposure event** — fires every
  time a visitor sees an experiment on a qualifying page view. GA4
  counts events.
* ABTestly counts **distinct test participants** — each visitor once
  per experiment.

On a SPA, a visitor can view the same tested page many times in one
session (open it, navigate away, come back, navigate again). Each view
fires an `experience_impression` event in GA4, but the dashboard still
counts that visitor once. GA4's number will usually be **higher** —
often substantially so on SPAs — and that does not mean either side is
wrong.

<Note>
  **Compare variants in GA4 with conversion *rates*, not raw counts.**
  Rates are robust to the events-vs-participants difference; raw
  impression counts are not. The
  [Segment recipe in the GA4 export docs](./ga4-export#analyzing-one-experiment-in-ga4-the-segment-recipe)
  walks through the right setup, including using **User segments** in
  GA4 when you want a per-visitor view that lines up with ABTestly's
  participant count.
</Note>

***

## 15. Tips for clean SPA results

* **Same snippet, same place.** Install the snippet the same way as on
  any other site — first script in `<head>`, no `async` / `defer`, no
  GTM. See [Installing the snippet](./snippet-install).
* **Prefer URL-based targeting.** URL conditions just work because
  targeting re-checks on every route change. JavaScript conditions
  also work, but they evaluate against the page state at the moment of
  the route change — if data loads later, conditions reading that data
  may not match.
* **Use `onApply` for DOM changes, not a top-level script.** Code at
  the top of the variant JS field runs once and never re-fires on
  navigation.
* **Use `waitFor` for anything that renders asynchronously.** And wait
  on a **page-specific** thing.
* **Undo JS in `onCleanup`; let us handle the CSS.** Editor-CSS-field
  rules are auto-cleaned. Listeners, body classes, and third-party
  initializations are yours.
* **Test with a preview link as you build.** Preview is SPA-aware —
  navigate through your app with the link active to confirm the
  lifecycle behaves the way you expect.

***

If something specific to your app's routing is giving unexpected results,
write us at [support@abtestly.com](mailto:support@abtestly.com) with the
URL, the experiment ID, and a description of what you expected vs. what
you saw. We'd rather hear about it than have you guess.

***

## Start testing

<Card title="Create a free ABTestly account" icon="flask" href="https://app.abtestly.com/sign-up" horizontal>
  The free tier covers 3,000 monthly tracked users and one active experiment, no credit card. Edge-served snippet, with sample size and confidence intervals shown on every result.
</Card>
