Developer Demo
Web Experimentation
Web Experimentation → CMS Content, on the First Pageview #
Teams who already run Web Experimentation and then adopt a headless CMS all hit the same question: how do I make my WX experiments serve content from the CMS content model? There are three ways to get a WX-style change onto the page. Two of them decide on the server. The third keeps WX as the decisioning engine, and that is what the rest of this page is about.
Ordering is what makes it work. WX decides in the browser, after the server has already sent the page, so it cannot influence that response. But the WX snippet is a blocking script in the document head. Its decision therefore exists before the browser parses the body or paints anything. That is the opening. The page can read a decision WX has already made, synchronously, with no network call and nothing stored.
Author it in Web Experimentation
Build the change in WX's own visual editor. The snippet mutates the page in the browser, with no CMS or Graph involved. Its advantage is reach: it can change anything on the rendered page, including things nobody modeled in the CMS, without waiting on a developer. The tradeoff is that the content does not live in your CMS. If you only need changes the CMS already supports, the server-side routes are cleaner.
no CMS neededDecide server-side, serve CMS content
Use this when the content must come from the CMS, so editors own it and it is server-rendered and cached. Make the decision before the HTML is sent. Feature Experimentation handles experiments. The ODP-direct and custom paths handle personalization. The variant is in the first response, so there is no lag and no flicker.
recommendedBridge WX to the CMS
Use this when you must keep WX as the decisioning engine and the content must come from the CMS content model. The page reads the decision WX already made, before first paint, and renders the matching CMS variation on the same pageview. There are two ways to get that variation on screen, and they cost different things. Both are covered below.
first pageviewHow the bridge works
A CMS variation whose name starts with wx_ opts its page into the bridge. That prefix does three jobs at once. It is the allowlist, so a made-up name can never reach Graph. It is the per-page opt-in, so a page with no wx_ variation emits no extra markup and pays nothing. And it keeps WX clear of the unprefixed names, which are reserved for matching Feature Experimentation variation keys.
On a page that has opted in, the server sends a short inline script. The script reads getExperimentStates synchronously. It intersects the variation names WX bucketed this visitor into with the names this page actually has. On a match it has a winner, and the delivery route takes over from there.
The base response is identical for every visitor, so it stays a single cached entry. The buckets do not fragment the cache.
Identity is already shared. optimizelyEndUserId is written domain-wide by middleware, and it is the same identifier the Web snippet uses. Both products see the same visitor, with no extra coordination.
Two Delivery Routes, Switchable in Settings #
The decision is the same in both routes. What differs is how the variation reaches the screen. Flip between them in the Settings panel (bottom-right) to see the difference on this instance, measured against a live WX experiment on /investments.
Route 1 - Soft navigation
defaultThe server sends base content only. The script hides the region, the page fetches the variation route, then the clean URL is put back. The variation is not in the first paint, so the visitor waits on one cached request.
- hold engages ~110-220ms, before first paint
- variation visible ~300ms warm, ~1500ms cold
- one extra request · base response cached once
Route 2 - Pre-paint swap
fastestThe server sends base and the variant in one response. The script names the active one on <html> and CSS shows it, so the variation is in the first paint.
- variation in the first paint
- no request, no hold, no URL change
- costs ~44KB raw, ~2.6KB gzip of duplicate markup
Both routes start from the same read. It runs inline in the head, after the blocking WX snippet, so the decision is already in memory. No network call, no storage, no callback.
// Emitted by the page only when it has wx_* variations.
var states = window.optimizely
.get("state")
.getExperimentStates({ isActive: true });
for (var id in states) {
var s = states[id];
// A holdback visitor is the control, so they get base
// content. Only this shape exposes the flag at all:
// getVariationMap() would report a bucket for them and
// skew the test.
if (s.isInExperimentHoldback) continue;
live[s.variation.name] = s.experimentName;
}
// NAMES is the page's own wx_* variation list, so an
// unknown name can never reach Graph. Iterating NAMES
// keeps the winner deterministic across experiments.
for (var i = 0; i < NAMES.length; i++) {
if (live[NAMES[i]]) { hit = NAMES[i]; break; }
}
// From here the two routes diverge.Route 2 - Pre-paint Swap #
In this route the server sends both versions in one response, and CSS picks one before the first paint. There is no second request and nothing to hold: by the time anything is painted, the page is already showing the right version.
Three pieces do the work. The page renders two subtrees and tags each one. A static stylesheet hides the variant by default. The inline script sets one attribute on <html>, which reverses that default.
One response, one paint
Server responds
base + variant in one HTML
WX snippet runs
decision in memory
Inline read
matched name
Attribute on <html>
data-cms-variation
First paint
variant only
No attribute means no flip, and the default CSS already shows base. JavaScript off, WX blocked, holdback and no-experiment all land there.
1. What the server renders
The variant is taken from the items Graph already returned for this page, so rendering both versions costs no extra round trip. Each subtree is wrapped in a div carrying a fixed token, __base__ or __variant__, and the variant also carries its real name. The tokens are fixed because the CSS is static: a stylesheet in the layout head cannot match a variation name that changes per page.
// Pre-paint needs base AND the variant in one response. Capped at a
// single variant, which is all an experiment needs: the visitor is in
// exactly one bucket.
const wxDual = wxVariations.length === 1 && !decidedVariationApplies;
// The variant comes out of the items Graph ALREADY returned, because
// the page asked for the wx_ name in the same variation filter. No
// second query, no second round trip.
const wxVariantPage = wxDual
? matchedItems.find((i) => i._metadata?.variation === wxVariations[0])
: null;
return (
<>
{/* Base. data-wx-region is still here because soft-nav mode is
switchable at runtime and needs something to hold. */}
<div data-cms-variant="__base__" data-wx-region="">
<OptimizelyComponent content={page} />
</div>
{/* The variant. Fixed token + the real name alongside it, so the
script can check the name and the CSS can match the token. */}
<div data-cms-variant="__variant__" data-cms-variant-name="wx_treatment">
<OptimizelyComponent content={wxVariantPage} />
</div>
</>
);2. The CSS that picks one
Three rules, in one static string in the root layout's head. The first hides the variant, which is the state every visitor starts in. The other two only apply once <html> carries the attribute, and they reverse it.
/* Default: base shows, the variant is hidden. Nothing has run yet,
so JavaScript off, WX blocked, a holdback visitor and "no matching
experiment" all render base correctly with no script at all. */
[data-cms-variant="__variant__"] { display: none }
/* After the flip: the two rules swap over. */
html[data-cms-variation] [data-cms-variant="__variant__"] { display: revert }
html[data-cms-variation] [data-cms-variant="__base__"] { display: none }
/* Two rules of thumb this encodes:
Use display, not visibility. A visibility-hidden element still
intersects the viewport, so AutoTracker's IntersectionObserver
would fire a second mb_feature_viewed for the hidden copy.
display:none generates no boxes at all.
Static, never a per-page <style> next to the content. That was
tried: it adds a sibling node the client tree does not have in
the same place, which shifts alignment by one and produces a
hydration mismatch. */3. The line that flips it
One setAttribute is the entire swap. On a hard load it runs in the head, before anything is painted, and it is never removed: the page is showing the right version, so there is nothing to undo. The add-and-remove pair belongs to the other route, where the attribute is a temporary hold rather than a permanent choice.
// Inline script in <head>, after the blocking WX snippet. Both
// subtrees are already in the document at this point.
// DUAL is false when the server sent no variant. Setting the
// attribute then would hide base and show nothing, so fall back
// to the soft-nav route instead.
if (!DUAL) prepaint = false;
var hit = g.read(); // { v: "wx_treatment", e: "Hero test" }
if (hit && prepaint) {
// The whole swap. One attribute, before the first paint: the CSS
// above now hides base and shows the variant.
root.setAttribute("data-cms-variation", hit.v);
return; // no hold, no timer, no request
}
// A CLIENT-SIDE navigation has no blocking script, and React does
// not re-run an inline one, so WxVariationSwap repeats the flip
// after hydration. Same attribute, same CSS, but the reveal is a
// visible change rather than something the visitor never sees.
if (hit && revealable(hit.v)) {
document.documentElement.setAttribute("data-cms-variation", hit.v);
}
// revealable() checks the variant is really on the page:
// document
// .querySelector('[data-cms-variant="__variant__"]')
// ?.getAttribute("data-cms-variant-name") === name4. Both copies are live
This is the part that catches people. CSS hides pixels, it does not stop JavaScript. Both subtrees are in the DOM, so both hydrate and both run their effects. Any client component placed inside a variation has to check whether it is the copy on screen.
export function isInactiveVariant(el: Element | null): boolean {
const wrapper = el?.closest("[data-cms-variant]");
if (!wrapper) return false;
// Which token is live right now. The same attribute the flip set
// is the single source of truth, so CSS and JS can never disagree.
const active = document.documentElement.hasAttribute("data-cms-variation")
? "__variant__"
: "__base__";
return wrapper.getAttribute("data-cms-variant") !== active;
}
// Three components guard with it today:
// FxBucketingEvent decide() fires a REAL impression, so a
// duplicate permanently skews the experiment
// AutoTracker would double-count mb_feature_viewed
// RecommendationBlockClient would fetch twiceWhat it costs
Speed is where this route wins, and that is the point of it. There is no hold and no request, so a bucketed visitor's LCP matches a base visitor's. CLS is untouched too, because the flip lands before the first paint and nothing on screen ever changes.
The bill arrives in the payload, and everyone pays it. The server cannot know which bucket a visitor is in, so the duplicate subtree ships to every visitor of that page, including the holdback and everyone in no experiment at all. That is the exact inverse of soft navigation, where only bucketed visitors pay anything.
The ~2.6KB gzipped figure flatters it, too. Compression only helps the wire. The browser still parses both copies, builds DOM nodes for both, and React still hydrates both, so the real cost is DOM size and main-thread hydration work rather than the ~44KB raw. That shows up as TBT and INP, how long the main thread is blocked and how quickly the page answers a tap, and it is felt on low-end devices rather than on your laptop. isInactiveVariant() stops the hidden copy firing duplicate analytics, but it does not stop the work.
For SEO, the page now contains the content twice. A crawler sees two <h1> elements on any page whose variation contains a product hero, every heading and paragraph twice, and every alt string twice. Google does index display: none content and discounts it, so this is unlikely to hurt rankings, but it muddies the page outline and it will confuse any audit tool you point at the page. Duplicate element ids are also invalid HTML, which is the real reason forms are unsupported inside a variation: a <label for> can resolve to the hidden copy. Anchor targets and aria-labelledby have the same hazard, and if you ever add JSON-LD to a block, the page will carry two copies of the same structured data. One image caveat belongs here too: a hero background with priority preloads from the head regardless of CSS, so the hidden copy would compete for LCP bandwidth.
Where this route beats the other outright is URL hygiene. No __v_ URL is ever created, so there is nothing to canonicalise, nothing to keep out of the sitemap and nothing that can leak.
Two limits to plan around. A client-side navigation has no blocking script, so the reveal lands after the paint and the visitor watches base content change: the same flicker soft navigation has, in the one case pre-paint cannot avoid. And the route handles exactly one variation per page. An A/B/C test falls back to soft navigation automatically, which is the right behaviour but not always the one you expected.
Setting It Up #
Create the CMS variation in Visual Builder
wx_ prefix, for example wx_treatment. Edit the variation's composition and publish it. Variations can only be created in the Visual Builder UI. No REST path creates one.Name the WX variation identically
wx_treatment, case-sensitive. That is the entire WX-side configuration: no Custom JS action and no snippet edit. Name the control variation anything that is not a CMS variation name and it falls through to base content, which Graph's includeOriginal: true guarantees. A visitor in the experiment's holdback also gets base content. The bridge checks that explicitly, because serving a holdback visitor the variation would corrupt the result.Verify it without a live WX experiment
/savings/__v_wx--wx_treatment, and confirm it returns the variation rather than a 307. A name the CMS does not have is redirected away, so the same request with a made-up name is the negative test. For the browser half, open a page that has a wx_ variation and call window.__optiWxCms.read() in the console. It returns the matched name and experiment, or null when the visitor is in no matching bucket.FX and Web Experimentation can coexist
ODP Segments as WX Audience Attributes #
Sharing a visitor ID is not the same as sharing what you know about that visitor. The traffic above runs one way only: out of WX and into the server. The return leg is a small client component. It pushes the shared profile into WX as user attributes: persona, ODP segment, top category, and the variation that was served.
That is the integration worth knowing about. WX decides in the browser, so it only sees what the browser can see. ODP holds cross-session behaviour and the CMS holds the taxonomy. Pushing both in as WX user attributes means an audience built on a behavioural ODP segment or a content category becomes targetable from the WX visual editor, with no deploy.
Caveats and Costs #
The two routes side by side, then the things that are true of both.
| Soft navigation | Pre-paint swap | |
|---|---|---|
| First paint (LCP) | Held region stays blank until the variation lands: ~300ms warm, ~1800ms cold | Variation is in the first paint, so the same as base content |
| Layout shift (CLS) | None. visibility keeps the box, so nothing moves | None. The flip happens before the first paint |
| Main-thread work (INP) | An RSC fetch plus a full subtree reconcile, right after hydration | Both subtrees hydrate and run their effects |
| Extra requests | One per bucketed visitor, cached and shared by the bucket | None |
| HTML payload | Base only | Base plus the variant: ~44KB raw, ~2.6KB gzip on this page |
| Who pays it | Only visitors bucketed into a matching variation | Every visitor of the page, bucketed or not |
| What gets indexed | Base content | Base content, plus a hidden second copy in the source |
| URL hygiene | A __v_ URL exists; canonical and sitemap keep it out of the index | No extra URL exists at all |
| Multiple variations | Any number | One only, otherwise it falls back to soft navigation |
What both routes cost you
The WX snippet is a blocking script in the head. That is deliberate, and it is what makes the whole bridge possible, but it means every visitor on every page waits for it before the first paint, whether or not they are in an experiment. It is the entry fee for this approach, and it is paid site-wide.
A WX variation also cannot change the page's <title>, meta description or OG image, in either route. generateMetadata strips variation segments before it looks anything up, so the metadata always comes from the base page. If your test rewrites the headline, the search result and the social card still show the original.
And crawlers get base content either way, because neither route changes the server response for them. For an A/B test that is exactly right, and it is what Google asks for. For personalization it is a real limitation: the segment-specific content is invisible to search, so anything that has to rank must live in the base version.
When soft navigation loses its own race
The hold narrows the flicker, it does not remove it. Against a warm ISR entry the swap is roughly 300ms and nothing flashes. Against a cold entry for the variation route it has been measured at ~1800ms, which outruns the 1500ms hold failsafe. The hold releases, base content paints, and the visitor watches it change: the worst of both, and the reason the failsafe is not set higher is that a longer blank region is worse still. If you can move the decision to the server, with FX or the ODP-direct paths, both the hold and the flicker go away. That is the honest reason the server-side routes are still marked recommended.
Some activation modes decide too late to hold
The synchronous read only works because the snippet is blocking and the page uses WX's default immediate activation. With polling, callback or manual activation, or an audience that needs a network call, the decision lands after the first paint. The bridge still applies the variation, but as a visible change rather than a held region. Holding speculatively would tax every visitor to help a few.
This demo carries both routes at once
The route switch is client-side, so it can be flipped without a server round trip. That means both routes ship the duplicate markup here. The demo shows the timing difference faithfully but not the payload difference, so read the payload row of the table as what you would get in production, where you would pick one route and render only that.
exp_variant_string alongside FX variations.Personalization, Not Just Experiments #
Everything above is written in terms of an A/B experiment, but the delivery does not care. A Web Experimentation Personalization campaign buckets visitors by audience rather than by traffic split, and the bridge reads it exactly the same way. The campaign's variation name is matched against this page's wx_ variations, and the matching CMS content is served.
The one difference worth knowing is the holdback. An experiment has a control group that must keep seeing base content, which is why the reader checks isInExperimentHoldback. A personalization campaign usually has no holdback, so every qualifying visitor gets the variation. That makes pre-paint the obvious route: there is no measurement to protect, and no reason to accept a held region.
How this reference implementation does it3 files
// Web Experimentation -> CMS variation, decided before first paint.
//
// The WX snippet is a blocking <script> in <head> (layout.tsx), so by the time any
// body markup is parsed the bucketing decision already exists in the browser, and
// window.optimizely.get("state") can be read synchronously with no network call.
// That is the whole mechanism: read the variation NAME, match it against the
// variations this page actually has in the CMS, and route the render to it.
//
// We read getExperimentStates({ isActive: true }) rather than the more obvious
// getVariationMap(). Verified against the live snippet, each entry is
// { id, experimentName, variation, audiences, reason, visitorRedirected,
// isActive, isInExperimentHoldback }
// and only this shape exposes the holdback flag. getVariationMap() reports a bucket
// for holdback visitors too, and a holdback visitor must see BASE content - they are
// the control that makes the experiment measurable. Reading the map would serve them
// the variation and quietly corrupt the result.
// (Note for anyone cross-checking against Optimizely's docs: the field is
// `isInExperimentHoldback`, built from `isInCampaignHoldback`. There is no
// `isInHoldback` in the snippet.)
//
// Dependency-free on purpose, same constraint as variationPath.ts: middleware, the
// catch-all page and a client component all import this, and none of them should
// pull the FX SDK or the Graph client in with it.
// Reserved flag-key namespace for the __v_ segment. A WX decision has no FX flag
// behind it, so it must not look like one: `servedFlagKey` feeds <FxBucketingEvent>,
// which calls FX decide() and fires a real impression. Tagging the segment `wx`
// keeps that guard to one comparison, and makes the segment's provenance obvious in
// a URL. It is also why a WX variation needs no phantom flag mirrored into the FX
// datafile just to pass validation.
export const WX_FLAG_KEY = "wx";
// Only CMS variations whose name starts with this participate in the WX bridge.
//
// The prefix is load-bearing, not cosmetic. It is simultaneously the allowlist, the
// per-page opt-in (a page with no wx_* variation emits no script and pays nothing),
// and the namespace partition from the FX path - CLAUDE.md already requires a CMS
// variation name to match an FX variation key exactly, so without a prefix a WX
// experiment with a variation named `personal` would hijack the FX/ODP persona
// variations on the homepage. An editor turns the feature on by creating a variation
// in Visual Builder; no deploy, no env var, and no phantom FX flag.
export const WX_VARIATION_PREFIX = "wx_";
export function isWxVariation(name: string | null | undefined): boolean {
return typeof name === "string" && name.startsWith(WX_VARIATION_PREFIX);
}
/** The page's wx_* variation names: deduped and sorted so the winner is stable. */
export function selectWxVariations(names: Array<string | null | undefined>): string[] {
return [...new Set(names.filter(isWxVariation) as string[])].sort();
}
// Set on <html> while the swap is in flight. A static rule in layout.tsx hides the
// held region; `visibility` rather than `display` so layout is reserved and the hold
// costs no CLS.
export const WX_PENDING_ATTR = "data-wx-pending";
export const WX_REGION_ATTR = "data-wx-region";
// Where the pre-paint script publishes its result for the client component.
export const WX_GLOBAL = "__optiWxCms";
// Fired when the decision resolves too late for the synchronous read (manual or
// callback activation, or an audience that needed async data). See below.
export const WX_EVENT = "opti-wx-cms";
// Failsafe ceiling on the hold. In the normal case the hold is released the moment
// the variation renders (WxVariationSwap's __v_ branch), so this only fires when the
// swap fails or never completes - including the case where React never hydrates,
// which is why the timer lives in the inline script rather than in a component.
//
// 600ms was measured to be too tight: against a COLD ISR entry for the variation
// route the swap landed at ~1800ms, so the failsafe fired first and the visitor got a
// flash of base content before the variation - the worst of both. A warm entry swaps
// in ~300ms. 1500ms covers the cold case, matches Optimizely's own anti-flicker
// guidance, and equals SETTLE_TIMEOUT_MS in lib/tracking/activeVariations.ts.
export const WX_HOLD_MS = 1500;
// Rendered once, statically, in the root layout's <head>.
export const WX_HOLD_STYLE = `html[${WX_PENDING_ATTR}] [${WX_REGION_ATTR}]{visibility:hidden}`;
// Two delivery mechanisms, switchable from the demo Settings panel so the two can be
// compared on a real page.
//
// soft-nav (default) - hold the region, soft-navigate to /__v_wx--<name>, restore the
// clean URL. One extra cached request; the variation is NOT in the
// first paint.
// pre-paint - the server already sent base AND the variant; flip an attribute
// on <html> before first paint and CSS reveals the right one. No
// request, no hold, no URL change.
//
// The choice lives in localStorage, not a cookie, on purpose: a cookie cannot change a
// statically prerendered page (which is why FX routes its decisions through a middleware
// segment rewrite), so a server-side switch would cost the catch-all its prerender. The
// consequence, which the demo page states plainly: the server sends both subtrees in
// BOTH modes, so the demo shows the timing difference honestly but not the payload
// difference. In production you would pick one and render only that.
export const WX_MODE_KEY = "demo_wx_mode";
export const WX_MODE_SOFTNAV = "softnav";
export const WX_MODE_PREPAINT = "prepaint";
export type WxMode = typeof WX_MODE_SOFTNAV | typeof WX_MODE_PREPAINT;
/** Reads the mode in the browser. Soft-nav unless pre-paint was explicitly chosen. */
export function readWxMode(): WxMode {
try {
return window.localStorage.getItem(WX_MODE_KEY) === WX_MODE_PREPAINT
? WX_MODE_PREPAINT
: WX_MODE_SOFTNAV;
} catch {
// Blocked storage (private mode, cleared site data): fall back to the default.
return WX_MODE_SOFTNAV;
}
}
// Marks each rendered subtree. The tokens are FIXED, not variation names, and the
// variant's real name rides along in WX_VARIANT_NAME_ATTR - that is what lets the reveal
// CSS be a static constant in the layout instead of a per-page <style> tag.
//
// Emitting that <style> next to the content was also a real bug: it added a node to the
// page's sibling list that the client tree did not have in the same place, which shifted
// alignment by one and produced a hydration mismatch (server `<a data-component=
// "ProductCardBlock">` against a client `Column` <div>). Keep the reveal CSS static.
export const WX_VARIANT_ATTR = "data-cms-variant";
export const WX_VARIANT_NAME_ATTR = "data-cms-variant-name";
export const WX_ACTIVE_ATTR = "data-cms-variation";
export const WX_BASE_VARIANT = "__base__";
export const WX_VARIANT_TOKEN = "__variant__";
/**
* The reveal CSS. Static, because the tokens are fixed - it lives in the root layout's
* <head> alongside WX_HOLD_STYLE and never varies per page.
*
* Default state is base-visible and the variant hidden, which is what makes JS-off,
* WX-blocked and no-decision all render base correctly without a line of script running.
* The pre-paint script flips it by setting WX_ACTIVE_ATTR on <html>.
*
* `display`, not `visibility`: AutoTracker's IntersectionObserver has no visibility
* check, and a `visibility:hidden` element still intersects, so a hidden duplicate
* would fire mb_feature_viewed a second time. `display:none` generates no boxes.
*/
export const WX_REVEAL_STYLE = [
`[${WX_VARIANT_ATTR}="${WX_VARIANT_TOKEN}"]{display:none}`,
`html[${WX_ACTIVE_ATTR}] [${WX_VARIANT_ATTR}="${WX_VARIANT_TOKEN}"]{display:revert}`,
`html[${WX_ACTIVE_ATTR}] [${WX_VARIANT_ATTR}="${WX_BASE_VARIANT}"]{display:none}`,
].join("");
/**
* True when `el` sits inside a variant subtree that is NOT the active one.
*
* Both subtrees hydrate, and effects run regardless of CSS, so any client component that
* fires an analytics event or a fetch on mount must check this or it does its work twice.
* Three call sites need it today: FxBucketingEvent (which fires a REAL FX impression, so
* a duplicate permanently skews the experiment), AutoTracker, and
* RecommendationBlockClient. Any new client component placed inside a variation needs it
* too - see CLAUDE.md.
*/
export function isInactiveVariant(el: Element | null | undefined): boolean {
if (!el) return false;
const wrapper = el.closest(`[${WX_VARIANT_ATTR}]`);
if (!wrapper) return false;
const active = document.documentElement.hasAttribute(WX_ACTIVE_ATTR)
? WX_VARIANT_TOKEN
: WX_BASE_VARIANT;
return wrapper.getAttribute(WX_VARIANT_ATTR) !== active;
}
export interface WxCmsGlobal {
/** The matched CMS variation name, or null when nothing matched (yet). */
v: string | null;
/** WX experiment name behind the match, for analytics attribution. */
e: string | null;
/** The page's wx_* allowlist, so a soft navigation can re-read without the script. */
names: string[];
/** Installed by wxReaderScript(); absent only if that never ran. */
read?: () => { v: string; e: string | null } | null;
/** Which mechanism the pre-paint script chose, for the Settings panel to display. */
mode?: string;
/**
* The clean path whose swap has already been started, latched here rather than in
* component state because WxVariationSwap may remount across the navigation. It is
* also what lets the address bar be restored to the clean path without the
* resulting pathname change starting the swap over again.
*/
doneFor?: string | null;
}
// Serialize for embedding in an inline <script>. Variation names come from the CMS,
// so escaping `<` is what stops a name containing "</script>" from closing the tag.
function embed(value: unknown): string {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
/**
* Source for the inline pre-paint script.
*
* `variationNames` is the allowlist: the variations this page actually has. The
* intersection with WX's buckets does double duty - it validates the name, and it is
* how we pick which experiment is the CMS-content one when a visitor sits in several
* WX experiments at once. That means zero WX-side configuration: naming a WX
* variation to match a CMS variation name is the entire setup.
*/
export function wxPrepaintScript(variationNames: string[], dualRendered: boolean): string {
return [
wxReaderScript(),
"(function(){",
`var NAMES=${embed(variationNames)},G=${embed(WX_GLOBAL)},A=${embed(WX_PENDING_ATTR)},E=${embed(WX_EVENT)},HOLD=${WX_HOLD_MS};`,
`var ACTIVE=${embed(WX_ACTIVE_ATTR)},MODEKEY=${embed(WX_MODE_KEY)},PREPAINT=${embed(WX_MODE_PREPAINT)},DUAL=${dualRendered ? "1" : "0"};`,
"var g=window[G];g.names=NAMES;",
"var root=document.documentElement;",
// localStorage can throw outright on a blocked-storage profile, and this script
// must never be the reason a page fails to render.
"var prepaint=false;try{prepaint=localStorage.getItem(MODEKEY)===PREPAINT;}catch(e){}",
// Pre-paint can only reveal a variant the server actually sent. Without one, setting
// the attribute would hide base and show nothing, so fall back to the soft-nav path.
"if(!DUAL)prepaint=false;",
"g.mode=prepaint?PREPAINT:'softnav';",
// Never act twice. A __v_ path is already the variation render (a direct hit, or the
// soft navigation that this script triggered on the previous view).
`if(location.pathname.indexOf(${embed("/__v_")})!==-1)return;`,
"var hit=g.read();",
"if(hit){",
" g.v=hit.v;g.e=hit.e;",
// Pre-paint mode: the variant is already in the document, so naming the active one
// is the entire swap. No hold, no failsafe, no request - CSS does it before paint.
" if(prepaint){root.setAttribute(ACTIVE,hit.v);return;}",
" root.setAttribute(A,'');",
" setTimeout(function(){root.removeAttribute(A);},HOLD);",
" return;",
"}",
// Nothing decided synchronously. Do NOT hold - most visitors are in no
// experiment at all and must not pay for it. Listen instead: if a decision lands
// late (polling, callback or manual activation, or an audience that needed a
// network call) the swap still happens, just as a visible change rather than a
// held region. Registering the listener here is correct precisely because these
// decisions have not happened yet: WX listeners are not retroactive, which is
// also why the synchronous read above cannot be replaced by one.
"try{",
" window.optimizely=window.optimizely||[];",
" window.optimizely.push({type:'addListener',filter:{type:'lifecycle',name:'campaignDecided'},handler:function(){",
" if(g.v)return;",
" var late=g.read();",
" if(!late)return;",
" g.v=late.v;g.e=late.e;",
// In pre-paint mode a late decision still applies, just after first paint - the
// reveal is a visible change rather than something the visitor never sees. The event
// is dispatched either way so attribution is recorded in both modes.
" if(prepaint){root.setAttribute(ACTIVE,late.v);}",
" try{document.dispatchEvent(new Event(E));}catch(e){}",
" }});",
"}catch(e){}",
"})();",
].join("");
}
/**
* The state read, as a string so it can be installed on the global by the inline
* script AND reused verbatim by the client component after a soft navigation, where
* React does not re-execute an inline script and so the reader would otherwise be
* gone. Kept in one place because a drifted copy would be a silent wrong-variant bug.
*/
export function wxReaderScript(): string {
return [
`(function(){var G=${embed(WX_GLOBAL)};`,
"var g=window[G]=window[G]||{v:null,e:null,names:[]};",
"g.read=function(){",
" try{",
" var o=window.optimizely;",
" if(!o||typeof o.get!=='function')return null;",
" var states=o.get('state').getExperimentStates({isActive:true})||{};",
" var live={};",
" for(var k in states){",
" var s=states[k];",
// A holdback visitor is the control. Serving them the variation would corrupt
// the experiment, and this flag is the only reason we read states not the map.
" if(!s||s.isInExperimentHoldback)continue;",
" var n=s.variation&&s.variation.name;",
" if(n&&!live[n])live[n]={v:n,e:s.experimentName||null};",
" }",
// Iterate the allowlist, not WX's output, so the winner is deterministic when a
// visitor is bucketed into more than one matching variation.
" for(var i=0;i<g.names.length;i++){if(live[g.names[i]])return live[g.names[i]];}",
" }catch(e){}",
" return null;",
"};})();",
].join("");
}
/** The `__v_wx--<name>` segment for a matched CMS variation name. */
export function wxVariationSegment(variationName: string): string {
return `__v_${WX_FLAG_KEY}--${variationName}`;
}
/**
* The path a matched variation should render at: the current path with the WX
* segment appended, matching how middleware builds the FX segments.
*/
export function wxVariationPath(pathname: string, variationName: string): string {
return `${pathname.replace(/\/$/, "")}/${wxVariationSegment(variationName)}`;
}
"use client";
import { startTransition, useEffect, useSyncExternalStore } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
readWxMode,
WX_ACTIVE_ATTR,
WX_EVENT,
WX_GLOBAL,
WX_MODE_PREPAINT,
WX_PENDING_ATTR,
WX_VARIANT_ATTR,
WX_VARIANT_NAME_ATTR,
WX_VARIANT_TOKEN,
wxReaderScript,
wxVariationPath,
type WxCmsGlobal,
} from "@/lib/optimizely/wxVariation";
import { clearVariation, recordVariation } from "@/lib/tracking/activeVariations";
// Second half of the WX bridge. The inline pre-paint script (see
// wxVariation.ts) has already read the WX decision synchronously and, on a match,
// hidden the held region. This component does the routing: a soft navigation to the
// __v_wx--<name> path, which is the same URL shape middleware builds for FX, so the
// catch-all's existing extractVariations/variationFilter path renders the variation
// with no change to the Graph query. The address bar is then put back to the clean
// path, so the segment is an implementation detail rather than something visitors see.
//
// Soft navigation rather than a reload: the RSC payload for that path is ISR-cached
// and shared by everyone in the bucket, so the swap is one cached fetch.
//
// It also has to handle client-side navigation. Two separate things break on a soft
// nav, and both are fixed here:
// 1. React does not execute an inline <script> it inserts during a navigation, so
// the pre-paint reader is not re-installed - hence the injection below.
// 2. WX itself does not re-evaluate on a History API navigation, so without an
// explicit activate push it keeps reporting the decision for the page the
// visitor originally landed on. Every internal link here is a next/link soft
// nav, so without this the bridge would only ever work on the entry page.
// Neither can hold pre-paint (there is no blocking script in a client navigation),
// so on a soft nav the swap is a visible change rather than a held region.
//
// IMPORTANT: assume this component REMOUNTS across the swap. The pre-paint script and
// the region wrapper are only rendered on a clean path, so the sibling structure
// changes and React may discard the instance. Nothing here may depend on state
// surviving the navigation - hence the latch on the global rather than a ref, the URL
// restore running on the variation render rather than from a settle effect, and the
// reconciled attribution below rather than a record-on-mount / clear-on-unmount pair.
// Indexed by the shared constant rather than a declared property name, so the
// accessor here can never drift from the name the inline script writes.
const globalState = (): WxCmsGlobal | undefined =>
(window as unknown as Record<string, WxCmsGlobal | undefined>)[WX_GLOBAL];
function clearHold() {
document.documentElement.removeAttribute(WX_PENDING_ATTR);
}
// Ask WX to re-evaluate for this URL. Harmless on a hard load (the snippet has already
// activated) and required after a client-side navigation, which WX does not notice.
function pushActivate() {
try {
const wx = window.optimizely as unknown as { push?: (e: unknown) => void } | undefined;
if (typeof wx?.push === "function") wx.push({ type: "activate" });
} catch {
// WX blocked or not loaded: fall through to base content.
}
}
// True when the server actually rendered a subtree for this variation name.
function revealable(name: string): boolean {
const el = document.querySelector(`[${WX_VARIANT_ATTR}="${WX_VARIANT_TOKEN}"]`);
return !!el && el.getAttribute(WX_VARIANT_NAME_ATTR) === name;
}
// Runs the one canonical reader. Injected as a real <script> element because that
// executes, where dangerouslySetInnerHTML during a navigation does not. Same source
// string the server inlines, so the holdback and match rules cannot drift.
function ensureReader() {
if (typeof globalState()?.read === "function") return;
const el = document.createElement("script");
el.textContent = wxReaderScript();
document.head.appendChild(el);
el.remove();
}
// What we last reported to the shared variation map. Module scope, so it survives the
// remount and a later navigation can still retract it. Without this, the old
// instance's unmount would clear the entry the swap had just recorded, and GA4 events
// would lose exp_variant_string.
let reported: string | null = null;
function reportVariation(rule: string | null, variation: string | null) {
if (reported && reported !== rule) {
clearVariation(reported);
reported = null;
}
if (rule && variation && reported !== rule) {
recordVariation(rule, variation);
reported = rule;
}
}
// Puts the clean path back in the address bar once the variation is on screen.
// usePathname() follows a manual replaceState, so the effect below re-runs with the
// clean path; the doneFor latch is what stops that from starting another swap.
function restoreCleanUrl(clean: string) {
try {
if (!window.location.pathname.includes("/__v_")) return;
window.history.replaceState(
null,
"",
clean + window.location.search + window.location.hash
);
} catch {
// Leave the URL as it is. The variation is already rendered, and a visible
// segment is cosmetic next to breaking the page.
}
}
export default function WxVariationSwap({ variations }: { variations: string[] }) {
const router = useRouter();
const pathname = usePathname();
// Nothing here may run during hydration. The server sends BOTH subtrees; the variation
// route sends one. Acting from the mount effect let the replacement tree land while
// React was still hydrating the two-subtree HTML, throwing "server rendered HTML didn't
// match the client" on roughly half of first loads. A setTimeout was measured to be
// insufficient.
//
// useSyncExternalStore is the signal: React takes the server snapshot (false) while
// hydrating and the client snapshot (true) afterwards, re-rendering once when they
// differ. Same shape as WxProfileBridge, and unlike a setState-in-effect it does not
// trip react-hooks/set-state-in-effect. The subscribe is a no-op because this never
// changes again after hydration.
const hydrated = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
useEffect(() => {
if (!hydrated) return;
ensureReader();
const state = globalState();
// A variation is on screen: either our swap landed, or this URL was requested
// directly, or FX decided. Release the hold, and if it was our swap, re-assert
// attribution (the remount may have retracted it) and hide the segment again.
if (pathname.includes("/__v_")) {
clearHold();
const clean = state?.doneFor;
if (clean && clean !== pathname) {
reportVariation(state?.e ?? "wx", state?.v ?? null);
restoreCleanUrl(clean);
}
return;
}
if (!state) {
clearHold();
return;
}
state.names = variations;
// Pre-paint mode. On a hard load the inline script has already revealed the variant and
// this is only attribution. On a CLIENT-SIDE navigation neither of those happened: React
// does not execute the inline script, and WX does not re-evaluate for the new URL, so
// without the two calls below the page silently stays on base content. The reveal is
// post-paint here by necessity - a client navigation has no blocking script.
if (readWxMode() === WX_MODE_PREPAINT) {
clearHold();
pushActivate();
const hit = state.v ? { v: state.v, e: state.e } : (state.read?.() ?? null);
// Only reveal a variant the server actually sent. Setting the attribute when no
// matching subtree exists would hide base and show nothing - the same trap the
// inline script's DUAL guard covers.
if (hit && revealable(hit.v)) {
state.v = hit.v;
state.e = hit.e;
if (!document.documentElement.hasAttribute(WX_ACTIVE_ATTR)) {
document.documentElement.setAttribute(WX_ACTIVE_ATTR, hit.v);
}
reportVariation(hit.e ?? "wx", hit.v);
return;
}
// No variant on the page to reveal: fall through to the soft-nav path, which can
// still fetch the variation route.
}
// Already swapped this page and the URL has been cleaned: the variation is on
// screen even though the path looks untouched. Re-running the read here is what
// would otherwise swap forever.
if (state.doneFor === pathname) {
clearHold();
reportVariation(state.e ?? "wx", state.v ?? null);
return;
}
// A clean path with no swap of ours: drop any variation carried over from the
// previous page so it stops tagging events here.
reportVariation(null, null);
const go = (name: string, experiment: string | null) => {
if (state.doneFor === pathname) return;
state.doneFor = pathname;
state.v = name;
state.e = experiment;
reportVariation(experiment ?? "wx", name);
window.dataLayer = window.dataLayer ?? [];
window.dataLayer.push({
event: "optimizely_decision",
optimizely_source: "web_experimentation",
optimizely_rule: experiment ?? "wx",
optimizely_variation: name,
});
startTransition(() => {
router.replace(wxVariationPath(pathname, name), { scroll: false });
});
};
pushActivate();
const hit = state.read?.() ?? null;
if (hit) {
go(hit.v, hit.e);
return;
}
// No decision yet. The region was never held (or the hold has its own timer), so
// reveal now and wait for a late campaignDecided in case this page uses manual
// activation or an audience that needed a network call.
clearHold();
const onLate = () => {
const late = state.read?.() ?? null;
if (late) go(late.v, late.e);
};
document.addEventListener(WX_EVENT, onLate);
return () => document.removeEventListener(WX_EVENT, onLate);
}, [hydrated, pathname, router, variations]);
return null;
}
"use client";
import { useEffect, useSyncExternalStore } from "react";
import { useVisitorProfile } from "@/lib/optimizely/useVisitorProfile";
import { getActiveVariations } from "@/lib/tracking/activeVariations";
// Hands Web Experimentation the same visitor profile everything else reads.
//
// WX already shares the optimizelyEndUserId cookie with FX (visitorCookie.ts writes
// it at registrable-domain scope), and a WX decision now drives CMS variations through
// wxVariation.ts + WxVariationSwap. What it could not do is see anything about the
// visitor: persona, ODP segments, the categories they read. So a marketer building a WX
// audience on a CMS taxonomy term or an ODP segment needed a developer. After this they
// do not.
//
// Timing, and it is worth being honest about on the demo page: the WX snippet is a
// blocking <script> in <head>, so window.optimizely exists well before this runs, but
// WX evaluates audiences at page-activation time and this push lands after hydration.
// The attributes therefore apply to the NEXT activation.
//
// That lag is specific to THIS component and is not shared by the variation bridge,
// which reads a decision WX has already made rather than trying to influence the one
// it is about to make. WxVariationSwap's activate push on navigation does partially
// mitigate it on soft navs, but a first-load audience built on these attributes still
// takes effect one activation late.
// Before the snippet loads, window.optimizely is a plain bootstrap array. Once it
// initialises, the snippet REPLACES it with its own object that exposes push(). So
// Array.isArray() is false on a page where WX is actually working - detect the push
// method instead. (Measured: the gate silently never fired with the isArray check.)
declare global {
interface Window {
optimizely?: { push: (event: unknown) => void } | unknown[];
}
}
function wxPush(): ((event: unknown) => void) | null {
const wx = window.optimizely;
return wx && typeof (wx as { push?: unknown }).push === "function"
? ((wx as { push: (event: unknown) => void }).push.bind(wx) as (event: unknown) => void)
: null;
}
export default function WxProfileBridge() {
// This mounts in the root layout, so an unconditional profile fetch would add an
// /api/profile round trip to every page load site-wide. Only pay it where WX is
// actually loaded and can use the attributes.
// The snippet is a blocking <script> in <head>, so this is already settled by the
// time React hydrates and never changes afterwards - hence the no-op subscribe.
// useSyncExternalStore rather than an effect, so nothing calls setState on mount.
const wxLoaded = useSyncExternalStore(
() => () => {},
() => wxPush() !== null,
() => false,
);
const { profile } = useVisitorProfile({ enabled: wxLoaded });
useEffect(() => {
if (!profile) return;
const variations = getActiveVariations();
const attributes: Record<string, string> = {
persona: profile.persona,
logged_in: String(profile.loggedIn),
device: profile.device,
};
// Only send what we actually know. An empty string would read as a real value in
// the WX audience builder and quietly match visitors we know nothing about.
if (profile.odpSegments.length > 0) attributes.odp_segment = profile.odpSegments[0];
if (profile.readCategories.length > 0) attributes.top_category = profile.readCategories[0];
const activeRules = Object.keys(variations);
if (activeRules.length > 0) {
attributes.fx_variation = `${activeRules[0]}-${variations[activeRules[0]]}`;
}
try {
wxPush()?.({ type: "user", attributes });
} catch {
// WX blocked or not loaded: personalization elsewhere must not be affected.
}
}, [profile]);
return null;
}