> ## 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>
