Developer Demo

Web Experimentation

Web Experimentation decides in the browser, after the server has already answered. Its snippet is a blocking script in <head>, so the decision exists before the body is parsed. That is what lets a client-side bucket serve server-rendered CMS content on the very first pageview.
✓ No cookie, no second pageview✓ Two delivery routes, switchableContent stays in the CMS

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 needed

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

recommended

Bridge 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 pageview

How 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

default

The 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

fastest

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

The shared read (inline, before any paint)
// 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 1 - Soft Navigation #

In this route the server knows nothing about the visitor's bucket. It sends base content, and the browser goes and gets the variation. The trick is that the page hides the affected region before the first paint, so the visitor does not watch base content turn into the variation.

Head - blocking, before any paint

WX snippet runs

decision in memory

Inline read

matched name

Region held

visibility: hidden

Same pageview - variation fetched in

Hydration

reads the matched name

router.replace

/__v_wx--wx_name

Graph filter

variation: { SOME }

Hold released

clean URL restored

A visitor in no matching experiment never sets the attribute, so nothing is held and base content paints as normal.

Step by step

  1. The inline script has a match, so it sets data-wx-pending on <html>. One static CSS rule in the layout head reacts to it and hides the region.
  2. The hold uses visibility, not display. The region keeps its box, so the layout does not move and the hold costs no CLS.
  3. After hydration, WxVariationSwap soft-navigates to /path/__v_wx--<name>. That is the same URL shape middleware builds for FX, so the catch-all route needs no new code.
  4. The route turns the segment into a Graph variation filter and returns the CMS variation. React reconciles it into the tree already on screen.
  5. The hold attribute is removed and the clean path is put back in the address bar with replaceState. A 1500ms timer in the inline script is the backstop, so a page that never hydrates still reveals itself.
The hold: one attribute, one rule
// The rule is static and lives in the layout <head>, so it
// is the same one line on every page:
//
//   html[data-wx-pending] [data-wx-region] { visibility: hidden }
//
// `visibility`, not `display`: the region keeps its box, so
// holding it cannot shift the page around (no CLS).

// ADD - inline script, before the first paint.
root.setAttribute("data-wx-pending", "");

// BACKSTOP - the timer lives in the inline script, not in a
// component, so it still fires if React never hydrates.
setTimeout(function () {
  root.removeAttribute("data-wx-pending");
}, 1500);

// REMOVE - WxVariationSwap, once the variation is on screen.
document.documentElement.removeAttribute("data-wx-pending");
WxVariationSwap.tsx (the swap)
// Routes to the variation using the same URL shape middleware
// builds for FX, so the catch-all page needs no new Graph
// query: it already reads __v_ segments.

const hit = window.__optiWxCms.read();

if (hit) {
  // Attribution first. WX reports its own conversions, but
  // exp_variant_string on GA4 events comes from here.
  recordVariation(hit.e, hit.v);

  startTransition(() => {
    router.replace(pathname + "/__v_wx--" + hit.v, { scroll: false });
  });
}

// On the next render the path contains __v_, which means the
// variation is on screen. That is where the hold is released
// and the clean URL is restored.
if (pathname.includes("/__v_")) {
  clearHold();
  restoreCleanUrl(state.doneFor);
}
The variation route ([[...slug]]/page.tsx)
// /savings/__v_wx--wx_treatment arrives here as
// slug = ["savings", "__v_wx--wx_treatment"]. The swap adds
// no route: this is the same code path FX segments took.

const { cleanSlug, activeVariations } = extractVariations(slug);
// cleanSlug        -> ["savings"]      the URL Graph is asked for
// activeVariations -> ["wx_treatment"] the variation filter

// includeOriginal is what keeps an unmatched visitor served:
// without it Graph returns nothing rather than base content.
// stored: false keeps Graph from freezing the variation value into a
// stored query template and serving it to every visitor.
const variationFilter = activeVariations.length > 0
  ? {
      variation: {
        include: "SOME",
        value: activeVariations,
        includeOriginal: true,
      },
      stored: false,
    }
  : undefined;

const items = await client.getContentByPath("/savings/", variationFilter);

// Graph can return the variation AND the base version, so
// pick the named one and fall back to base.
const page =
  items.find((i) =>
    activeVariations.includes(i._metadata?.variation ?? "")
  ) ?? items[0];

return <OptimizelyComponent content={page} />;
What reaches Graph
# getContentByPath() generates this from the resolved content
# type: one fragment per registered type, plus the variation
# name so the caller can tell which version came back.

query ListContent($where: _ContentWhereInput, $variation: VariationInput) {
  _Content(where: $where, variation: $variation) {
    items {
      __typename
      ...DynamicExperience
      _metadata { variation }
    }
  }
}

# Variables. The real where clause is an _or over the
# trailing-slash and hierarchical forms of the URL - one here.
{
  "where": { "_metadata": { "url": { "default": { "eq": "/savings/" } } } },
  "variation": {
    "include": "SOME",
    "value": ["wx_treatment"],
    "includeOriginal": true
  }
}

Nothing in that route knows a swap happened. router.replace fetches the RSC payload for the variation path and React reconciles it into the tree already on screen. There is no reload. The variation route is its own ISR entry, shared by everyone in the bucket, so the rest of the bucket pays no Graph round trip at all.

What it costs

The metric this route moves is LCP, the moment the biggest thing on screen finishes painting. The hold is visibility: hidden, which paints nothing, so nothing inside the held region can be the LCP element until the hold releases. For a bucketed visitor that pushes LCP out to the swap: roughly 300ms against a warm ISR entry, and ~1800ms measured against a cold one. If your hero sits inside the region, the hero is the LCP, so that delay is the whole above-the-fold experience.

CLS, how much the layout jumps around, is the one thing the design deliberately protects. Holding with visibility rather than display keeps the box, so revealing the region shifts nothing.

INP, how quickly the page answers a tap, gets the worst of it. The swap is an RSC fetch followed by React reconciling a whole page subtree, and it lands right after hydration, which is already the busiest moment on the main thread. There is also one extra request per bucketed visitor, and the variation route is its own ISR entry, so each wx_ variation doubles the cached entries for that page. The first visitor into a cold bucket pays the full Graph round trip, which is exactly the ~1800ms case.

For SEO this route is safe by default, with one gap worth closing yourself. The variation URL canonicalises back to the clean path, because generateMetadata strips the variation segment before it looks anything up, and the sitemap only ever lists clean routes. So /savings/__v_wx--wx_treatment should not be indexed as a page of its own. The gap is that robots.txt does not disallow __v_, and a canonical is a hint rather than a directive. If one of those URLs ever leaks into a link, a crawler can fetch it and get a 200. In production, a noindex on variation routes or a Disallow: /*__v_ line is the belt to that canonical's braces.

The consolation is that only bucketed visitors pay any of this. A visitor in no matching experiment, one in the holdback, or anyone with the snippet blocked never sets the attribute, so nothing is held and base content paints immediately.

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.

Rendering both versions ([[...slug]]/page.tsx)
// 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.

WX_REVEAL_STYLE (static, in the layout head)
/* 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.

The flip, on a hard load and on a client-side navigation
// 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") === name

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

isInactiveVariant() (wxVariation.ts)
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 twice

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

1

Create the CMS variation in Visual Builder

In the CMS, open the page you want to experiment on and click Add variation. Name it with the 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.
2

Name the WX variation identically

In the Web Experimentation UI, name the experiment's variation exactly the same string, 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.
3

Verify it without a live WX experiment

The two halves can be checked independently. For the server half, request the variation route directly, /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.
4

FX and Web Experimentation can coexist

FX and WX can run on the same page at the same time, and FX wins. FX decides on the server, so by the time the browser could read a WX decision the URL already carries an FX variation segment and the bridge stands down. The two never fight over the same render. A WX decision also deliberately fires no FX impression: there is no FX flag behind it, and recording one would put a phantom decision in FX's reporting.

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.

Timing is the catch, and it is the mirror image of the bridge above. WX evaluates audiences at page activation, and this push lands after hydration, so the attributes apply from the next activation. An audience built on them is therefore a second-pageview audience. That is exactly the lag the variation bridge was rebuilt to avoid, which is why it is worth knowing which direction you are relying on.

Caveats and Costs #

The two routes side by side, then the things that are true of both.

Soft navigationPre-paint swap
First paint (LCP)Held region stays blank until the variation lands: ~300ms warm, ~1800ms coldVariation is in the first paint, so the same as base content
Layout shift (CLS)None. visibility keeps the box, so nothing movesNone. The flip happens before the first paint
Main-thread work (INP)An RSC fetch plus a full subtree reconcile, right after hydrationBoth subtrees hydrate and run their effects
Extra requestsOne per bucketed visitor, cached and shared by the bucketNone
HTML payloadBase onlyBase plus the variant: ~44KB raw, ~2.6KB gzip on this page
Who pays itOnly visitors bucketed into a matching variationEvery visitor of the page, bucketed or not
What gets indexedBase contentBase content, plus a hidden second copy in the source
URL hygieneA __v_ URL exists; canonical and sitemap keep it out of the indexNo extra URL exists at all
Multiple variationsAny numberOne 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.

Web Experimentation statistics are unaffected. WX tracks its own decision events and conversions through the snippet. The bridge only changes which CMS content variant is served, so all statistical analysis stays in the WX dashboard. The variation and its experiment name are also recorded into the shared variation map, so GA4 events carry it in 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.

If the audience you want to target is a behavioural one, built from cross-session events rather than anything on the current request, define it in ODP and push it in as a WX attribute (see above), or skip WX entirely and let ODP drive Graph directly.
How this reference implementation does it3 files
src/lib/optimizely/wxVariation.ts
// 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)}`;
}
src/components/personalization/WxVariationSwap.tsx
"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;
}
src/components/personalization/WxProfileBridge.tsx
"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;
}