Skip to main content
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:

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.
The runtime logs a spa_stale_cancelled event when this check fires, visible in the 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.
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:
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. 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.