Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 113 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,37 @@ export async function runExperimentation(document, config) {
}
}

/**
* Loads the experimentation simulation UI (lazy).
* The simulation panel is an authoring aid, so this is a no-op in production —
* the coarse check below avoids even fetching the plugin there, and the plugin
* re-checks authoritatively before showing anything.
* @param {Document} document The document object.
* @param {Object} config The experimentation configuration.
* @returns {Promise<void>} A promise that resolves when the simulation UI is loaded.
*/
export async function runExperimentationLazy(document, config) {
const { host, hostname, origin } = window.location;
const isPreview = hostname === 'localhost'
|| hostname.endsWith('.page')
|| (typeof config.isProd === 'function' && !config.isProd())
|| (config.prodHost && ![host, hostname, origin].includes(config.prodHost));
if (!isPreview) {
return null;
}

try {
const { loadLazy } = await import(
'../plugins/experimentation/src/index.js'
);
return loadLazy(document, config);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load experimentation module (lazy):', error);
return null;
}
}

```

> **Note:** Add the following line to your `head.html` to preload the experiment loader script:
Expand All @@ -97,6 +128,7 @@ Add the following import and configuration at the top of your `scripts/scripts.j
```js
import {
runExperimentation,
runExperimentationLazy,
} from './experiment-loader.js';

const experimentationConfig = {
Expand All @@ -119,6 +151,17 @@ async function loadEager(doc) {
}
```

And add the following line to your `loadLazy()` function to enable the simulation
panel (see [Enabling the simulation panel](#enabling-the-simulation-panel-aem-sidekick) below):

```js
async function loadLazy(doc) {
// ... existing code ...
await runExperimentationLazy(doc, experimentationConfig);
// ... rest of your code ...
}
```

### Increasing sampling rate for low traffic pages

When running experiments during short periods (i.e. a few days or 2 weeks) or on low-traffic pages (<100K page views a month), it is unlikely that you'll reach statistical significance on your tests with the default RUM sampling. For those use cases, we recommend adjusting the sampling rate for the pages in question to 1 out of 10 instead of the default 1 out of 100 visits.
Expand Down Expand Up @@ -185,7 +228,14 @@ const experimentationConfig = {
/* handle custom decoration here, for example: */
buildBlock(el);
decorateBlock(el);
}
},

/* Which simulation UI to wire up in preview/dev (see the section below) */
// - 'auto' (default): wire up the AEM Sidekick panel if/when the Sidekick is present
// - 'sidekick': same as 'auto', but explicit
// - 'universal-editor': the panel is delivered as a UE extension, so stay out of the way
// - false: do not load any simulation UI
simulationUI: 'auto',
};
```

Expand All @@ -201,6 +251,68 @@ Fragment replacement is handled by async observer, which may execute before or a
3. Have a `.section` selector and need to redecorate => call `decorateBlocks(el)`
4. Have a `main` selector and need to redecorate => call `decorateMain(el)`

### Enabling the simulation panel (AEM Sidekick)

Starting with v2, the plugin no longer injects its own in-page overlay to simulate and switch
between experiment variants. That panel now ships as a micro-frontend (MFE) that Adobe's
[AEM Sidekick browser extension](https://chromewebstore.google.com/detail/aem-sidekick/igkmdomcgoebiipaifhmpfjhbjccggml)
opens on demand.

The plugin loads and toggles that panel for you from `loadLazy()` — you do **not** need to copy any
listener script into your project or add anything to `head.html`. Make sure you've wired
`runExperimentationLazy` into your `scripts.js` `loadLazy()` as shown in
[Step 2](#step-2-update-scriptsscriptsjs) above. The panel only loads in preview/development
environments, never in production, and only when the Sidekick is present (see the `simulationUI`
option to change this — for example in the Universal Editor, where the panel is delivered as a UE
extension instead).

> **Note on performance and preview timing.** The panel's UI and MFE loader live in a separate
> `simulation.js` chunk that `loadLazy` pulls in via a dynamic `import()` **only** in
> preview/development, so production pages never download or parse any simulation code. The small,
> UI-less `postMessage` handshake the panel talks over is the one exception: it's registered eagerly
> (preview-only, from `loadEager`) so that a Sidekick bookmarklet that injects the panel early in page
> load still finds a listener. Bumping the plugin therefore keeps the preview handshake working
> exactly as before — no migration needed — while still keeping the heavy panel UI off the production
> path.

The one thing the plugin can't do for you is register the Sidekick toolbar button, since that lives
in your project's Sidekick config, not in page code. Add the following plugin entry to
`tools/sidekick/config.json` in your project (a copy also lives at
[`documentation/sidekick/config.json`](./documentation/sidekick/config.json)):

```json
{
"plugins": [
{
"id": "aem-experimentation",
"titleI18n": {
"en": "Experimentation"
},
"environments": [
"preview"
],
"includePaths": [
"**.docx**"
],
"event": "aem-experimentation-sidekick"
}
]
}
```

> **Note:** if your project has been migrated to AEM's unified ("helix5") config format — for
> instance via [Experience Workspace](https://docs.da.live/about/early-access/experience-workspace) —
> committing this file alone is not enough. Sidekick reads its config from your persisted site config
> (`https://admin.hlx.page/config/{org}/sites/{site}.json`), not a live read of
> `tools/sidekick/config.json` off the code bus, so you need to merge a matching `sidekick.plugins`
> array into that persisted config directly. Note also that this admin API's verbs are inverted from
> typical REST conventions: **`PUT`** only *creates* a config that doesn't exist yet (and fails with
> `config already exists` if one is already there) — **`POST`** is what updates an existing config.

Once the button is registered and `runExperimentationLazy` is wired up, clicking the
"Experimentation" button in Sidekick opens the panel. A shared simulation link
(`?experiment=<experiment-id>/<variant-name>`) also opens the panel automatically on page load.

## Extensibility & integrations

The experimentation plugin exposes APIs that allow you to integrate with analytics platforms and other 3rd-party libraries.
Expand Down
10 changes: 8 additions & 2 deletions documentation/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,17 @@ The same spreadsheet can also contain the configuration for several pages at onc

### Simulation

Once all of this is set up, authors will have access to an overlay on `localhost` and on the stage environments (i.e. `*.hlx.stage`) that lets them see what experiment and variants have been configured for the page and switch between each to visualize the content variations accordingly.
> **Note:** the screenshot below and the "overlay pill" reflect the v1 plugin. In v2 that overlay is
> no longer injected by the plugin — the simulation panel is now delivered by the
> [AEM Sidekick extension](https://chromewebstore.google.com/detail/aem-sidekick/igkmdomcgoebiipaifhmpfjhbjccggml)
> and loaded by the plugin's `loadLazy()`. See
> [Enabling the simulation panel](../README.md#enabling-the-simulation-panel-aem-sidekick) in the main README.

Once all of this is set up, authors will have access, in preview/development environments, to a panel that lets them see what experiment and variants have been configured for the page and switch between each to visualize the content variations accordingly.

![audience overlay](./images/experiments-overlay.png)

The simulation capabilities leverage the `audience` query parameter that is appended to the URL and forcibly let you see the specific content variant.
The simulation capabilities leverage the `experiment` query parameter (`?experiment=<experiment-id>/<variant-name>`, e.g. `?experiment=hero-test/challenger-1`) that is appended to the URL and forcibly let you see the specific content variant.

### Inline Reporting

Expand Down
17 changes: 17 additions & 0 deletions documentation/sidekick/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"plugins": [
{
"id": "aem-experimentation",
"titleI18n": {
"en": "Experimentation"
},
"environments": [
"preview"
],
"includePaths": [
"**.docx**"
],
"event": "aem-experimentation-sidekick"
}
]
}
89 changes: 85 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function debug(...args) {
}
}

export const VERSION = '1.1.0';
export const VERSION = '1.2.0';

export const DEFAULT_OPTIONS = {

Expand All @@ -48,6 +48,13 @@ export const DEFAULT_OPTIONS = {

// Redecoration function for fragments
decorateFunction: () => {},

// Which simulation UI to wire up in preview/dev environments:
// - 'auto' (default): wire up the AEM Sidekick panel if/when the Sidekick is present
// - 'sidekick': same as 'auto', but explicit
// - 'universal-editor': the panel is delivered as a UE extension, so stay out of the way
// - false: do not load any simulation UI
simulationUI: 'auto',
};

const CONSENT_STORAGE_KEY = 'experimentation-consented';
Expand Down Expand Up @@ -1040,8 +1047,34 @@ async function serveAudience(document, pluginOptions) {
);
}

// Support new Rail UI communication
// sessionStorage key used to re-open the simulation panel after a variant
// switch forces a full-page reload. The reload handler below writes it; the
// lazily-loaded simulation UI reads it (see src/simulation.js).
const SIMULATION_PANEL_REOPEN_KEY = 'aem-experimentation-simulation-open';

let isCommunicationLayerInitialized = false;

/**
* Sets up the `postMessage` handshake the hosted simulation panel talks over.
*
* This is deliberately the *only* piece of simulation wiring that lives in the
* eager engine: it is a tiny message listener (no UI, no heavy MFE code) and it
* must be registered as early as possible. The AEM Sidekick bookmarklet can
* inject the panel's `client.js` during page load — before the lazy phase runs
* — and the MFE's config request is one-shot, so a listener registered only in
* `loadLazy` would miss it and the panel would come up blank/stale. The heavy
* MFE loader still lives in the lazily-imported `simulation.js`, so production
* pages neither download nor parse any of the panel UI.
*
* Both `loadEager` and `loadLazy` may call this; the guard makes every call
* after the first a no-op.
* @param {Object} options the plugin options
*/
function setupCommunicationLayer(options) {
if (isCommunicationLayerInitialized) {
return;
}
isCommunicationLayerInitialized = true;
window.addEventListener('message', async (event) => {
if (event.data && event.data.type === 'hlx:last-modified-request') {
const { url } = event.data;
Expand Down Expand Up @@ -1092,6 +1125,13 @@ function setupCommunicationLayer(options) {
event.data?.type === 'hlx:experimentation-window-reload'
&& event.data?.action === 'reload'
) {
// Preserve the panel's open state across the reload so it re-opens once
// the page comes back (see setupSimulationUI in src/simulation.js).
try {
window.sessionStorage.setItem(SIMULATION_PANEL_REOPEN_KEY, 'true');
} catch (e) {
debug('Failed to persist simulation panel state:', e);
}
window.location.reload();
}
});
Expand All @@ -1112,11 +1152,52 @@ export async function loadEager(document, options = {}) {
ns.audience = ns.audiences.find((e) => e.type === 'page');
ns.campaign = ns.campaigns.find((e) => e.type === 'page');

// Register the (tiny, UI-less) simulation handshake as early as possible so
// an eagerly-injected Sidekick bookmarklet doesn't race past it. Preview/dev
// only; the heavy panel UI is still deferred to loadLazy.
if (isDebugEnabled) {
setupCommunicationLayer(pluginOptions);
}
}

export async function loadLazy() {
// Placeholder for lazy loading functionality
/**
* Loads the simulation UI used to preview and switch experiment variants.
*
* Since v2 the simulation panel is a hosted micro-frontend that the AEM Sidekick
* extension opens on demand. It is an authoring aid, so it only runs in
* preview/development environments, never in production. The actual UI lives in
* a separate `simulation.js` module that is dynamically imported only when
* needed, so its code never ships or parses with the core engine.
*
* In the Universal Editor the panel is delivered as a UE extension instead, so
* pass `simulationUI: 'universal-editor'` (or `false`) to keep the plugin out of
* the way there. The default `'auto'` only activates when the Sidekick is present.
*
* Call this from your project's `loadLazy()` in `scripts.js`, alongside the
* `loadEager()` call in `loadEager()`.
*
* @param {Document} document The document object.
* @param {Object} options The experimentation configuration.
*/
export async function loadLazy(document, options = {}) {
const pluginOptions = { ...DEFAULT_OPTIONS, ...options };

// Authoring aid only — never surface the simulation UI in production.
if (!setDebugMode(window.location, pluginOptions)) {
return;
}

// In the Universal Editor a dedicated UE extension owns the panel.
if (pluginOptions.simulationUI === false || pluginOptions.simulationUI === 'universal-editor') {
return;
}

// Ensure the postMessage handshake is available even on preview pages that had
// no experiment configured when loadEager ran. No-op if already registered.
setupCommunicationLayer(pluginOptions);

// Load the simulation/preview UI on demand so it never ships with the engine.
// eslint-disable-next-line import/extensions
const { default: setupSimulation } = await import('./simulation.js');
setupSimulation(pluginOptions, document, SIMULATION_PANEL_REOPEN_KEY);
}
Loading
Loading