SPA Navigation
A variation is one specific version being tested: the control or one of the challengers. On single-page applications, clicking a link changes the URL without reloading the page. A vs B detects these route changes and automatically re-evaluates experiments for the new URL, keeping each visitor's variation assignment sticky throughout.
How route change detection works
The A vs B snippet watches for client-side route changes on every project, with nothing to turn on. It wraps the browser's pushState and replaceState methods, and listens for the popstate event, which fires when a visitor uses the Back or Forward buttons. When any of these fire, the snippet waits 50 milliseconds, checks whether the address actually changed, and only then starts the re-evaluation cycle below.
That short wait matters for two reasons. First, a page can call pushState or replaceState several times in one instant; the snippet only reacts to the address it settles on. Second, some apps call replaceState just to fix scroll position, with the address unchanged; the snippet skips those too, since nothing really navigated.
Your project has a separate toggle, under Project Settings, Configuration, Advanced settings, called Hash-based routing. It does not turn route detection on or off: that always runs. It only decides whether a changed #fragment (for example, /pricing#compare becoming /pricing#faq) counts as a new page. Leave it off, and a link to a page section still works, but does not restart your experiments. Turn it on only if your app actually routes with hash addresses, like example.com/#/pricing.
When the snippet decides a real navigation happened, it begins the re-evaluation cycle.
The re-evaluation cycle
On every route change, the snippet goes through the following steps in order.
Cleanup
Every active experiment variation, plus any trigger still waiting to activate, is torn down. So is Project JavaScript. Each variation's <style> tags are removed from the <head>.
Any timer, interval, event listener, or waitUntil wait started through the options self-cleaning helpers is cancelled automatically: you never cancel these yourself. Then A vs B calls any onRemove callback you registered in initVariation, initTrigger, or initProject. Use onRemove for cleanup the helpers cannot handle alone, like reverting DOM text or unsubscribing from a third-party script.
Project JavaScript re-runs
Your initProject function is called again for the new URL. This allows you to update segments, re-attach global listeners, or re-initialize anything that depends on the current page.
Experiments re-evaluate
Every running experiment is evaluated against the new URL. URL targeting rules (which page a visitor must be on), audience (a named, reusable group of visitors) conditions, and traffic allocation are all re-checked. An experiment that did not match the previous page might match the new one, and vice versa.
Variation injection
Experiments that match the new URL call the visitor's assigned initVariation (and initTrigger if present). The visitor sees the new page with the correct variations applied.
Anti-flicker is not re-applied on an ordinary SPA route change. Hiding and revealing the page every time would make your SPA feel sluggish and would fight your transition animations. Instead, re-evaluation happens fast enough that visitors do not notice a flicker.
There is one exception. If the new route matches a split-URL experiment's control address, A vs B briefly re-hides the page. This stops the visitor from seeing the control page render before a possible redirect fires. It reveals the page again the instant the redirect happens, or right away if no redirect turns out to be needed.
If your own variation makes a dramatic above-the-fold change, use a trigger instead. Delay it with options.waitUntil, until the target element exists in the new route's DOM.
Automatic teardown of timers, listeners, and waitUntil
Any timer, interval, event listener, or waitUntil wait started through the options helpers is automatically cancelled. This applies inside initVariation, initTrigger, and initProject alike, whenever that variation or Project JS is torn down on navigation. You do not need to cancel any of them yourself in onRemove.
Only resources that fall outside the helpers, like native IntersectionObserver instances, third-party subscriptions, or direct DOM text mutations, need an explicit onRemove callback.
Sticky bucketing across navigations
Bucketing is how A vs B sorts a visitor into a variation. It uses a consistent hash of their id, so the same visitor always lands in the same group. Even though experiments re-evaluate on every navigation, that assignment is sticky, meaning it does not change.
A variant is one specific version being tested, other than the control. Say a visitor is assigned Variant 1 of experiment "hero-headline-test" on your homepage. They navigate to your pricing page, then back to the homepage. They land in Variant 1 again, not a fresh random pick.
This is because assignments are stored in the visitor's cookie, and are worked out deterministically from their visitor ID and the experiment ID. The same inputs always produce the same output.
Example: SPA-safe trigger
Use the self-cleaning helpers to avoid manual cleanup in most cases:
/** @type {(options: AvsbTriggerOptions, activate: () => void, deactivate: () => void) => void} */function initTrigger(options, activate, deactivate) { // Waits started through options are auto-cancelled on navigation: no manual cleanup needed options.utils.waitForElement('.product-title').then((titleEl) => { activate(); // options.addEventListener is auto-removed on navigation options.addEventListener(titleEl, 'click', () => { options.track.event(99001); }); });}For resources that fall outside the helpers, register an explicit onRemove callback:
/** @type {(options: AvsbVariationOptions) => void} */function initVariation(options) { // Apply changes directly: initVariation runs at the activation moment const titleEl = document.querySelector('.product-title'); if (titleEl instanceof HTMLElement) { titleEl.dataset.originalText = titleEl.textContent ?? ''; titleEl.textContent = 'Try it free for 14 days'; } // DOM text changes are not auto-reverted: do it manually options.onRemove(() => { const original = document.querySelector('.product-title'); if (original instanceof HTMLElement && original.dataset.originalText) { original.textContent = original.dataset.originalText; } });}Exposure tracking on re-navigation
An exposure is the moment a visitor is actually counted in an experiment: when they are assigned a variation and it activates. Each time a variation activates, including every SPA re-navigation that lands the visitor back on it, the snippet sends a fresh exposure event. Revisiting the same page ten times sends ten events, not one.
Your results, though, still count that visitor once for this experiment. The dashboard counts distinct visitors, not raw events, so repeat exposures from the same visitor never inflate your participant counts.