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

# SPA lifecycle

> How ABTestly handles a single-page-app route change. Cleanup, re-eval, apply, plus the ApplyContext object and the routeRevision counter.

On a traditional multi-page site, every navigation is a full page
load. The snippet starts from scratch every time. Easy.

On a single-page app, navigations happen client-side. The URL changes,
the DOM is rewritten, but the page never reloads. The snippet has to
notice, tear down any variant that no longer applies, evaluate
targeting against the new route, and apply the variants that do.

## The flow

Every time the URL changes, in order:

1. **Cleanup phase.** For each experiment currently active whose
   Location no longer matches the new URL, run its
   `onCleanup` handlers (LIFO), disconnect any observers, remove
   `<style data-abtestly-exp="...">` tags.
2. **Re-evaluate targeting.** For each experiment, check Location
   and Audience against the new URL. Mid-session bucketing runs the
   deterministic hash, a visitor who was in Variant B on route one
   is still in Variant B on route two.
3. **CSS re-inject.** For experiments that now apply, inject their
   `<style>` blocks.
4. **Apply.** Run `onApply` handlers with an `ApplyContext`
   parameterized with the new route.
5. **Re-eval page-visit goals.** A page-visit goal for `/checkout`
   fires when the visitor navigates to `/checkout`, whether that
   navigation was a full load or a client-side push.
6. **Re-arm triggered activations.** Click and view triggers are
   re-installed for eligible experiments on the new route.

Steps 1–6 are wrapped in a single micro-task after the URL change,
so the visitor sees no intermediate state.

## What triggers a route change

* `history.pushState`, wrapped.
* `history.replaceState`, wrapped.
* `popstate`, listened to.
* `hashchange`, listened to.

Wrapping is done once per page (`window.__abtestlySpaWrapped` guards
against double-wrapping if you accidentally load the snippet twice).

Same-URL pushes are deduped, a router that calls `pushState(null,
'', location.href)` does not trigger a full cleanup and reapply.

## The ApplyContext

Every `onApply` callback receives an `ApplyContext`:

```js theme={null}
abtestly.onApply(ABTESTLY_EXP_ID, (ctx) => {
  ctx.experimentId;   // UUID
  ctx.experimentKey;  // short key
  ctx.variantKey;     // 'control' | 'variant-1' | …
  ctx.routeRevision;  // monotonic counter, ticks on every real nav
  ctx.libs;           // dev library accessor — ctx.libs.get('splide')
});
```

## routeRevision, detecting supersession

If your `onApply` does async work (a fetch, a `waitFor`), by the time
it completes the visitor might have navigated away. Applying stale
work to the new page would be a bug.

```js theme={null}
abtestly.onApply(ABTESTLY_EXP_ID, async (ctx) => {
  const startRev = ctx.routeRevision;
  const data = await fetch('/api/details').then(r => r.json());
  if (ctx.routeRevision !== startRev) return; // superseded, abort quietly
  render(data);
});
```

The runtime logs a `spa_stale_cancelled` event when this check
fires, visible in the [dev debugger](/preview/dev-debugger) so you
can see supersessions happen in the wild.

## Cleanup

`onCleanup` runs when the experiment is torn down, either because
the visitor navigated to a route the experiment does not match, or
because the experiment was un-published.

```js theme={null}
abtestly.onApply(ABTESTLY_EXP_ID, () => {
  const el = document.querySelector('.hero h1');
  const original = el.textContent;
  el.textContent = 'Try the new headline';

  abtestly.onCleanup(ABTESTLY_EXP_ID, () => {
    el.textContent = original;
  });
});
```

Cleanups run in **LIFO** order, the last cleanup you registered is
the first to run. Matches JavaScript's stack semantics; also matches
what most code expects intuitively.

## CSS is auto-torn-down

Any `<style>` block the runtime injected with a
`data-abtestly-exp="<id>"` attribute is auto-removed on cleanup. You
do not have to write a cleanup for CSS-only variants, the runtime
handles it.

JavaScript changes need their own cleanup because the runtime has no
way to know what you did.

## The route-change event

The runtime dispatches an `abtestly:routechange` event on `window`
after re-evaluation completes:

```js theme={null}
window.addEventListener('abtestly:routechange', (e) => {
  console.log(e.detail.url);
  console.log(e.detail.activeExperiments);
  console.log(e.detail.deactivatedExperiments);
});
```

Use it for global cross-experiment coordination, logging, warming
a cache, that does not fit inside any single variant.

## Turning it on

**Site → Settings → SPA support → Enable.** See
[SPA panel](/settings/spa-panel). Publish-gated.

Once on, everything above is automatic. Your variants opt into SPA
awareness by using `onApply` and `onCleanup` instead of running
naked code at the top level.
