Skip to content

Feat/wb ab testing#5349

Merged
SvenAlHamad merged 30 commits into
release/6.5.0from
feat/wb-ab-testing
Jul 16, 2026
Merged

Feat/wb ab testing#5349
SvenAlHamad merged 30 commits into
release/6.5.0from
feat/wb-ab-testing

Conversation

@SvenAlHamad

Copy link
Copy Markdown
Contributor

Summary

Adds end-to-end A/B testing to the Website Builder: editors define an experiment on a page, create full-page variants, set a traffic split, activate/deactivate, and edit each variant on the normal editor canvas. Variants are served server-side by the SDK with deterministic bucketing, a stable split, an instant kill-switch, and a provider seam for an exposure event (PostHog wiring is a follow-up). Built into core (not an extension).


  1. Storage & serving mechanics

Data model — two private Headless CMS models (never in the page's revision timeline):

  • wbyWbExperiment — belongs to a page (pageEntryId, baselineRevisionId), holds status (draft → running → stopped), name, trafficSplit ({ control, variants: { : weight } }), targeting, analytics, timestamps, winningVariantId.
  • wbyWbVariant — belongs to an experiment; a full page-like content snapshot (properties/elements/bindings/metadata/extensions) copied from the baseline, with its own draft/ready status.

Approval / governance — variants never serve unpublished. Serving reads published experiment/variant content by entryId; a PageAfterPublish handler cascades a publish to the page's running experiment + ready variants, so the page's existing approval workflow is the single gate.

How a variant is chosen (per request, in the SDK) — getPageWithExperiment(path):

  1. Fetch the control page + the active experiment for the path (cached per path). No running experiment → control.
  2. Kill-switch: an uncached getExperimentPaused check — a paused experiment instantly reverts to control (content stays cached).
  3. Resolve a visitor context (visitor id from cookie, country from CDN geo headers, device from UA).
  4. Bucketing (assignVariant): targeting gate → inclusion gate (hash(visitorId:experimentId:inclusion) vs trafficPercentage) → weighted pick over control + ready variants using cyrb53(visitorId:experimentId).
  5. Overlay the chosen variant's content onto the control; forced ?wb-variant= bypasses bucketing for QA.

Persistence & stickiness — no assignment is stored. Only a wb_ab_vid cookie (random UUID, 1-year, no PII, set in middleware) is persisted; the same visitor id always hashes to the same bucket, so assignment is sticky and stateless.

Split correctness — cyrb53 is uniform across visitor ids, so weight configured split; inclusion and bucket selection use independenthash seeds.

Caching — reads cache and key only on path / variant id, never on visitor id, so the CDN cache stays shareable; only the kill-switch read is no-store.

Permissions — experiments/variants have no separate permission entities; every read/write/publish/delete checks the corresponding wb.page action. The read-only frontend key (wb.page:r / $wb.readonly) can serve and preview them
---
2. SDK changes (website-builder-sdk / -react / -nextjs)

  • ContentSdk exposes getPageExperiment, getVariantContent, getExperimentPaused, and a bound getPageWithExperiment(path, options) — bucketing, targeting, overlay, exposure, caching, and the kill-switch are all encapsulated. optiones the forced-variant id) or an explicit forcedVariantId, plusvisitor overrides.
  • Kill-switch added: getExperimentPaused on IDataProvider/DefaultDaRIMENT_PAUSED query + a noStore path on ApiClient.query;getPageWithExperiment reverts to control for a non-running or paused experiment.
  • @webiny/website-builder-react re-exports the experiment surface (eVisitorContext, assignVariant, analytics-provider registration,FORCED_VARIANT_PARAM, DEFAULT_VISITOR_COOKIE, and all experiment types).
  • @webiny/website-builder-nextjs ships a middleware helper at @webidleware (createWebsiteBuilderMiddleware + default middleware/config)handling preview/draft mode and the wb_ab_vid cookie.

  1. Implementing the SDK in a frontend (to test this PR)

▎ These packages are unpublished on the branch, so link/build them n link the four @webiny/website-builder-* packages, or point the appat the built dist). The demo app in this workspace is already refactored to the shape below.

middleware.ts
export { middleware, config } from "@webiny/website-builder-nextjs/

app/[[...slug]]/page.tsx — the whole A/B path is one call:
import { contentSdk } from "@webiny/website-builder-nextjs";
import { initializeContentSdk, getTenant } from "@/src/contentSdk";

const { isEnabled: isPreview } = await draftMode();
initializeContentSdk({ preview: isPreview, tenantId: await getTenant() });

const { page } = await contentSdk.getPageWithExperiment(path, { searchParams: search });
return ;

Verify:

  1. In Admin, open a page → Experiments → create one with two variants and a split → edit each variant on the canvas → Activate → publish the page.
  2. Split/stickiness: load the page across several fresh sessions (cn tracks the weights; within one session it stays put.
  3. Forced preview: ?wb-variant= renders that variant (uncached, QA only).
  4. Kill-switch: pause the experiment → the page reverts to control

Known limitations / follow-ups

  • Exposure event not emitted yet — the provider seam exists; wiring the PostHog exposure call is the next step.
  • Draft-variant "preview in a new tab" stays app-side (reads an unph public serving deliberately doesn't expose).
  • Variant add/remove is locked when editing an existing experiment; no analytics/results UI yet.
  • Feature should be only available behind a feature flag, later this should be behind a WCP license

SvenAlHamad and others added 24 commits June 30, 2026 13:26
Add whole-page A/B testing across the API, the Next.js SDK, and the admin
data layer.

API (@webiny/api-website-builder):
- New private CMS models wbyWbExperiment and wbyWbVariant (tenant/locale
  scoped via the CMS layer like wbyWbPage).
- Experiment CRUD + lifecycle: create/update/get/list, start/stop with
  one-active-per-revision enforcement, and winner graduation that creates a
  new page revision (N+1) from a variant snapshot via CreateEntryRevisionFrom.
- Variant CRUD; a variant begins as a full content snapshot of the baseline
  revision and never appears in the revision timeline.
- PageAfterPublish handler structurally ends an active experiment when a
  different revision of the page is published.
- Public, SDK-facing reads: getPageExperiment(path) and getVariantContent(id),
  gated by the Website Builder API key (now granted wb.experiment/wb.variant r).
- New wb.experiment and wb.variant permission entities.

SDK (@webiny/website-builder-sdk):
- Deterministic, tenant-isolated bucketing (hash of visitorId + experimentId),
  geo/device targeting, traffic percentage, and a forced-variant override.
- Canonical, provider-agnostic ExposureEvent + AnalyticsProvider seam with a
  PostHog adapter (no PostHog dependency bundled; client is injected). The seam
  is GA4-ready — no provider field names reach the data model or render path.
- getPageWithExperiment helper: resolves the live revision, buckets server-side,
  serves control or a variant with no client swap, emits one exposure, and keeps
  the cache key on (path, bucket) only — never the raw visitor id.
- Bucketing unit tests.

Admin (@webiny/app-website-builder):
- ExperimentsGateway (headless feature) + useExperiments hook covering the full
  experiment/variant lifecycle, registered in the admin Extension.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the experiments management UI to the Website Builder admin, wired onto the
ExperimentsGateway.

- ExperimentsPresenter (MobX) loading a page's experiments and their variants,
  with create / add-variant / start / stop / graduate actions and an even
  traffic-split rebalance persisted on variant changes.
- An "A/B testing" action on each page row opens an experiments dialog
  (create experiment, manage variants, start/stop, graduate a winner). Creating
  an experiment is gated to published pages (live-revision-only) and an
  informational notice warns that publishing a new revision ends a running
  experiment.
- New wb.experiment / wb.variant entities added to the admin permissions schema.
- ExperimentsPresentationFeature and ExperimentsConfig registered in the admin
  Extension.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esting

Two bugs surfaced while testing the admin UI:

- "No experiment found" when adding a variant / starting an experiment: the
  admin passed the experiment's base entryId to operations whose server side
  resolves entries via GetEntryById, which matches only the exact revision id.
  Use the revision id consistently as the experiment/variant identifier — for
  the variant→experiment foreign key, the public read's variant lookup, the
  SDK-facing variantId (so getVariantContent resolves), the traffic-split keys,
  and the graduation ownership check.

- Forms stayed disabled after creating an experiment until the dialog was
  reopened: the child form components read presenter.vm.busy but were not MobX
  observers, and the parent dialog observer never reads busy, so flipping busy
  back to false triggered no re-render. Wrap CreateExperimentForm,
  AddVariantForm, ExperimentRow, StartControls and VariantRow in observer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the Website Builder editor to a variant's content so editors can make a
variant differ from the control.

- New route /website-builder/experiments/variant/:id/editor and a VariantEditor
  that mirrors the page editor (same editor scope + DefaultEditorConfig) but
  loads the variant's content snapshot and autosaves via updateVariant.
- VariantAutoSave debounces document changes into updateVariant (properties,
  metadata, bindings, elements, extensions) — the same fields page autosave uses.
- VariantEditorConfig adds a back button, a "Variant: <name>" title, and the
  variant autosave to the editor top bar (no page publish controls).
- An "Edit" button on each variant row opens the editor (hidden once graduated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he iframe

The variant editor reused the page editor, whose preview iframe addresses the
document as a page (wb.type=page, wb.id=<id>). For a variant this broke two ways:
the in-editor SDK keys its document store by the iframe's wb.id while the canvas
keys by properties.id, and the app's preview fetch resolved the id via the page
model.

- Pin the variant editor document's properties.id to the variant id so it matches
  wb.id — this aligns the editor's document store with the canvas store so live
  edits render. Keep metadata.documentType "page" so the in-editor SDK streams the
  document to the iframe.

The Next.js app side (separate repo) falls back to the published page by path when
the preview-by-id fetch misses, giving the editing canvas a valid base while the
in-editor SDK streams the actual variant document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the governance hole where a running experiment served draft variants with
no approval. Data model is unchanged; only the business logic and serving.

- Serve only PUBLISHED experiment/variant content, matched by page (not a frozen
  revision). Draft edits never serve.
- Page publish cascades: the PageAfterPublish handler now publishes the page's
  running experiment + ready variants. Since only page publish runs the approval
  workflow, approving + publishing the page is what takes them live.
- Public read (getPageExperiment) resolves the published running experiment by
  pageEntryId and derives participating variants from the traffic split (keyed by
  variant entryId); getVariantContent serves the published variant revision by
  entryId.
- Instant kill-switch: pause/resume an experiment via a runtime flag
  (KeyValueStore), no publish/approval — it only ever reverts to the approved
  control. Serving honours it via an uncached check.
- Admin: pause/resume controls + paused state; traffic split keyed by variant
  entryId.

The Next.js app (separate repo) adds the uncached pause check and serves the
published variant content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ep 1)

Remove the previous experiments/variants admin UI and start the UI fresh,
screen by screen.

- Add an "Experiments" button (flask icon + chevron) to the page editor top bar
  via the page editor config.
- Clicking it opens a right-side Experiments drawer showing the empty state:
  an A/B illustration, "No experiments on this page", a description, a
  "Create experiment" button (wired to a no-op for now), and a
  "Learn about A/B testing" link.

The create-experiment flow is the next screen. API business logic and the
Next.js SDK serving are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Anchor the top-bar Experiments action with `before="buttonPublish"` so it sits
immediately to the left of the Publish button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Experiments drawer now navigates from the empty state to a "New experiment"
form (back arrow returns to the overview):

- Experiment name; experiment key auto-generated from the name (slugified) and
  editable, with the analytics-key info note.
- Variants & traffic split: Control (current page) + Variant B by default, with
  per-variant sliders that auto-balance to 100%, a live Total%, and Add variant
  / remove controls (variants labelled B, C, D…).
- Cancel + Create experiment footer.

Create currently closes the drawer; persisting to the API is the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In the New experiment form, variant names are now editable inputs (Control stays
fixed), and every row (Control + variants) has an optional description field. The
create payload carries each variant's name + description and the control
description.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each control/variant row now shows labelled fields — Name, Key, Description, and
a labelled Traffic slider — for a cleaner layout. Every variant carries its own
key, auto-generated from its name (slugified) and editable; Control's name is
fixed while its key/description remain editable. The create payload includes each
variant's key and the control key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once experiments exist, the top-bar button becomes a switcher:

- Orange flask button showing the current experiment name + chevron.
- Dropdown with a "Now viewing" section (Original page — no experiment applied),
  an "Experiments" section listing each experiment with a status dot + Active/
  Inactive badge and a check on the current selection, and a "Manage
  experiments…" action.

Experiments are held in local editor state for now (creating one in the drawer
adds it and flips the button to the switcher). Applying a variant to the canvas
preview is intentionally out of scope for this step; persistence/real data comes
later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hook the experiments UI up to the API:

- Re-add the admin ExperimentsGateway (list/get/create/update experiment,
  list/create/update/delete variant) over the Website Builder GraphQL, plus a
  useExperiments hook that orchestrates create (experiment → variants with
  copied baseline content, marked ready → traffic split written with the real
  variant ids; experiment key stored in the analytics config).
- The top-bar button loads the page's experiments from the API on mount and
  becomes the switcher when any exist.
- "Manage experiments…" opens the drawer's list view (experiment name, status,
  variant count) with New experiment and per-row Edit.
- Edit reuses the experiment form (prefilled from the persisted experiment +
  its variants) to update name, key, traffic split and variant names;
  variant add/remove is locked in edit for now.

Variant key/description aren't persisted yet (no model fields) — on edit the key
is re-derived from the name and description starts empty. pageEntryId /
baselineRevisionId come from the edited document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a toolbar between the address bar and the canvas that appears once an
experiment is selected in the top-bar switcher:

- Shows the experiment name, its status (Active/Inactive), and the bucket
  currently being edited (Control or a variant, with its traffic weight).
- "Switch variant" opens a menu listing the control and every variant with
  weights and a tick on the current one, plus an "Edit experiment" shortcut
  that opens the drawer straight into edit mode.

Selection is shared between the top-bar switcher and this toolbar through a new
editor-wide ExperimentsEditorProvider (installed via an Ui.Layout decorator),
which now owns experiment loading, the current experiment/variant selection and
the experiments drawer. ExperimentsButton and the drawer consume it; the drawer
gained an initialEdit prop for the toolbar's direct-edit path.

Switching a variant updates the toolbar's indicator; driving the canvas to load
the selected variant's content is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Redesign the drawer's experiment list as cards and wire the lifecycle actions.

Admin:
- Each experiment renders as a card: name, status, a variant preview per bucket
  (control + variants) with traffic weights, a proportional traffic bar, a
  legend, and Activate/Deactivate, Edit and Delete actions.
- A header explains that only one experiment can run at a time, with a New
  button. Activating an experiment stops the page's currently running one first;
  deactivating stops it; deleting removes the experiment and its variants (with
  a confirmation dialog). Actions report through snackbars and reload the list,
  so the top-bar switcher and in-preview toolbar stay in sync.
- ExperimentsGateway gains startExperiment/stopExperiment/deleteExperiment.

API:
- Add a DeleteExperiment feature (use case + repository) and a
  deleteExperiment(id) mutation.
- Allow StartExperiment to start a "stopped" experiment (not just "draft") so it
  can be re-activated after being deactivated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bels

The New, Activate and Delete buttons on the experiment cards used raw SVGs that
defaulted to black; set fill to currentColor so each icon inherits its button's
text colour (white on New/Activate, red on Delete).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Selecting a variant in the in-preview toolbar now loads that variant into the
editor so it can be viewed and edited like the page; selecting Control returns
to the page.

- Lift ExperimentsEditorProvider above the DocumentEditor (in PageEditor) so the
  selected bucket can drive which document the editor mounts. The provider now
  takes the page revision id as a prop and also exposes the selected variant.
- PageEditorSurface mounts the editor for the selected bucket: the page (control)
  with the usual page config, or a variant mapped onto an equivalent editor
  document (id / properties.id = variant revision, presented as a "page" on the
  page's path, so the site's editing SDK streams and renders it live) with a
  VariantPageEditorConfig that swaps page chrome for a variant autosave. Content
  is (re)fetched on each switch so a bucket always shows its latest saved state.
- VariantAutoSave persists edits back to the variant via updateVariant; gateway
  gains getVariant (full content) and updateVariant accepts content fields.

No API or Next.js site changes: the editing canvas already renders whatever the
editor streams, and getVariant/updateVariant already exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Editing a variant streamed wb.id = the variant revision id, so the site's
preview resolved the server-side base via getPageById(variantId) — which fails,
since a variant isn't a page (and the published-by-path fallback fails when the
page is only a draft), leaving the canvas blank.

The variant's editor document now keeps the page's identity (id / properties.id
/ path) so the preview base resolves to the page draft exactly like the control
does, while its content (elements, bindings, properties) is the variant's. The
editor streams that content over the base on connect, and autosave still targets
the actual variant via context. Control and variant therefore share wb.id; the
switch is driven by re-mounting the editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The address-bar "preview in a new tab" / copy-link buttons previewed the page
even while editing a variant, because a variant's editor document borrows the
page's identity (id/path) so the preview base resolves.

The variant document now carries an editor-only marker (metadata
wbVariantPreviewId, stripped before persisting) with the variant's revision id.
usePreviewLink detects it and builds a link to the page path with
`?wb-variant-draft=<revisionId>` instead of the page preview params, so the new
tab renders the variant's current draft content (the site fetches it by revision
id via getVariant, which the public API key can read).

The Next.js site side (resolveVariantDraftDocument + a page branch for
wb-variant-draft) lives in the demo app repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…permission

Experiments and variants are content that belongs to a page, so they no longer
have their own permission entities — every experiment/variant read, write,
publish and delete now checks the corresponding wb.page action. This means the
read-only frontend API key (which already has wb.page:r / $wb.readonly) can
serve and preview draft variants, exactly as it already reads draft pages; no
separate Experiments/Variants toggles and no extra grants on the key.

- Route all experiment/variant use-case permission checks to "page".
- Drop the experiment/variant entities from the API and admin permission
  schemas.
- Drop the now-obsolete wb.experiment/wb.variant grants from the API key
  installer.

Fixes the variant "preview in a new tab" 404: getVariant was failing
authorization (masked as "not found") because the key had no variant read
permission.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…leware

Move the A/B serving algorithm fully behind the SDK so apps don't reimplement
bucketing, overlay, caching and the kill-switch.

- SDK: getPageWithExperiment already lived here; add the missing kill-switch —
  IDataProvider/ContentSdk gain getExperimentPaused (uncached via a new no-store
  path on ApiClient), and getPageWithExperiment now reverts to the control for a
  non-running or paused experiment. It also accepts `searchParams` and derives
  the forced-variant id itself.
- react: re-export the experiment surface (getPageWithExperiment,
  resolveVisitorContext, assignVariant, forcedAssignment, analytics-provider
  registration, the constants and all experiment types) so it's reachable from
  @webiny/website-builder-nextjs.
- nextjs: ship a middleware helper (createWebsiteBuilderMiddleware + default
  middleware/config) handling preview/draft mode and the stable A/B visitor
  cookie, exported at @webiny/website-builder-nextjs/middleware.

End-developer DX becomes: `contentSdk.getPageWithExperiment(path, { searchParams })`
in the page, and a one-line middleware re-export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…colours

- Right-align the experiment form's Cancel / Save buttons (were left/stretched).
- Introduce a shared variant colour palette (variantColors.ts) and use it in the
  form, the list cards, and the in-preview toolbar so each variant shows the same
  colour everywhere. The toolbar's "Switch variant" list and current-bucket pill
  no longer render every variant orange — they follow the assigned colour by
  index.
- Make the switcher's flask/chevron icons inherit the button's colour
  (currentColor) so they're orange with the label instead of black.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…utton

Only the switcher (shown once experiments exist) has a chevron; with no
experiments the button is a plain "Experiments" action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a published (read-only) page the top bar now shows the running experiment as
an indicator — flask + name + Active/Inactive — with a Pause/Resume button that
toggles the kill-switch (reverts serving to control instantly, no republish).
The in-preview toolbar drops the experiment identity (now in the top bar) and
reads "Previewing" instead of "You're editing", with no "Edit experiment" entry.

- Admin gateway gains pauseExperiment/resumeExperiment/getExperimentPaused
  (keyed on the experiment entryId, matching the serving/getPageExperiment id).
- The editor context tracks the selected experiment's paused state and exposes
  pause/resume; the read-only top-bar auto-selects the running experiment so the
  toolbar tracks it.
- Draft pages keep the switcher (before Publish); the read-only indicator is
  registered before the version menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@Pavel910 Pavel910 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reviewed the api-* part of the PR. In general the only important thing to change is that we never return null from use cases. It's a general rule - use cases either return a record, or throw an error. The errors are nicely typed, and can be easily used for flow control. Adding more edge cases is easy, you simply add more error types.

Comment thread packages/api-website-builder/src/index.ts Outdated
…ases

Apply the review feedback on the api-* layer:

- Use cases and repositories never return `null`. GetActiveExperimentForPath and
  GetActiveExperimentForRevision now fail with typed errors: NoActiveExperimentError
  when there's no running experiment, and ExperimentPausedError when the kill-switch
  is on. The getPageExperiment/getActiveExperiment resolvers map those (and page
  not-found) back to a null response so the SDK still reads "serve the control",
  and StartExperiment treats NoActiveExperiment as free-to-start.
- Rename the repository's single public method to `execute`.
- Split ExperimentPause into one file per use case (Pause/Resume/IsPaused).
- Register a single top-level ExperimentFeature and VariantFeature (in
  features/experiments/feature.ts and features/variants/feature.ts) instead of
  wiring each sub-feature individually in index.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SvenAlHamad

Copy link
Copy Markdown
Contributor Author

(c/p from my Claude Code :) )
Thanks Pavel — all addressed in 8d3f9a8:

  • No null from use cases/repositories: GetActiveExperimentForPath and GetActiveExperimentForRevision now return a record or Result.fail(...). Added NoActiveExperimentError and ExperimentPausedError; the two "get active" repositories fail with NoActiveExperimentError instead of Result.ok(null).
  • execute naming: renamed the repository method to execute.
  • Paused error class: the use case now returns Result.fail(new ExperimentPausedError(...)).
  • Split file: ExperimentPause is now one file per use case (Pause/Resume/IsPaused).
  • Top-level features: added ExperimentFeature and VariantFeature; index.ts registers only those two.

One thing to flag: the serving query getPageExperiment legitimately has a "no experiment on this page" outcome. To keep that from being an error the SDK logs on every plain page, the resolver maps NoActiveExperiment/Paused to a null data response (still typed failures inside the use case, just translated at the GraphQL boundary). Happy to adjust if you'd prefer a different convention there.

@brunozoric brunozoric left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote few comments, but these stand out:

  • Most abstractions.ts have same problem - more than one abstraction defined.
  • Too much useEffect in your components. Move to presenters.

Comment thread packages/api-website-builder/src/domain/experiment/experiment.model.ts Outdated
Comment thread packages/api-website-builder/src/domain/variant/variant.model.ts Outdated
Comment thread packages/app-website-builder/src/presentation/experiments/ExperimentsDrawer.tsx Outdated
SvenAlHamad and others added 2 commits July 6, 2026 09:14
- Drop the WEBINY_API_LEGACY_MODELS conditional from the experiment/variant
  models; these are new models, so use "wbyWbExperiment" / "wbyWbVariant"
  directly.
- Use the repo's slugify library for experiment/variant keys instead of a local
  helper (in NewExperimentForm and ExperimentsDrawer).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Bruno's convention, split every experiment/variant feature's abstractions.ts
into an abstractions/ folder with one abstraction per file
(feature/Something/abstractions/Something.ts), matching api-opensearch. Shared,
non-abstraction exports (e.g. experimentPauseKey) get their own file; each
feature's index.ts stays the public barrel, re-exporting from the new files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SvenAlHamad and others added 2 commits July 6, 2026 10:31
…view)

Rewrite the editor-embedded experiments UI to the webhooks presentation pattern
(MobX Presenter + ViewModel + DataSource + feature + observer views), removing
the React context and the useEffect-based data loading Bruno flagged.

- ExperimentsEditor: the shared hub presenter (experiments, selection, variant
  options, kill-switch state) + its data source; the top-bar switcher/indicator
  and the in-preview toolbar are observer views of it. Replaces
  ExperimentsEditorContext + useExperiments.
- ExperimentsManager: drawer/list presenter owning drawer view state and the
  per-experiment variant summaries for the cards (loaded via a reaction, so the
  card no longer fetches in a useEffect); lifecycle actions delegate to the hub.
- ExperimentForm: create/edit form presenter + observer view.
- shared/ (variantColors, variantDocument) and config/ (ExperimentsEditorConfig,
  VariantAutoSave). PageEditor inits the hub presenter; PageEditorSurface reads
  the selected variant from it. Features registered in Extension.tsx.

Editor selectors (isReadOnly) and the autosave subscription remain thin effects,
mirroring the framework's own PageAutoSave.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…conventions

Follow-ups from checking the rewrite against webiny-admin-architect:

- Drop split callback props: ExperimentCard now takes the manager presenter and
  drives activate/deactivate/edit/delete directly, owning its own snackbar +
  delete confirmation (like WebhookListContent). ExperimentsListView takes only
  the presenter; the drawer no longer threads onActivate/onDelete/etc.
- Move view derivation into the hub ViewModel: variant options now carry their
  colour, and the vm exposes currentBucket, runningExperiment and switcherItems,
  so the toolbar and switcher read display-ready data instead of computing it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SvenAlHamad

Copy link
Copy Markdown
Contributor Author

Thanks @brunozoric — all addressed. Rundown:

Models & keys — 783ace2

  • Dropped the WEBINY_API_LEGACY_MODELS conditional; models are declared directly as wbyWbExperiment / wbyWbVariant.
  • Keys now go through the repo's slugify package rather than the hand-rolled helper.
  • On .compress() / .encrypt(): left off deliberately — the stored fields are small config JSON (traffic split, targeting rules, analytics id + key), so there's nothing large enough to warrant compression and no secrets/credentials to encrypt. Happy to add them if you'd still prefer.

One abstraction per file — d547cbc

  • Every experiment/variant abstractions.ts is split into abstractions/.ts (one interface/type per file), matching the api-opensearch layout you pointed to. Each feature's index.ts stays the public barrel.

Presenters (webhooks pattern) — ff6d7ed, refined in cf58418

  • Rewrote the admin experiments UI to the webhooks/src/admin shape: MobX Presenter + ViewModel + DataSource + feature (resolve in singleton scope) + observer views. The React context and useEffect-based data loading are gone — a hub presenter plus a mobx reaction handle experiments/selection and the per-card variant summaries, with init() / dispose() on the view lifecycle.
  • Child components take the presenter and drive it directly (no split callback props), and all display data (bucket colours, current bucket, switcher items) is derived in the ViewModel rather than in the components. The only remaining effects are editor selectors (isReadOnly) and the autosave subscription, mirroring the framework's own PageAutoSave.

One intentional deviation — presenter scoping. The ExperimentsManager (drawer) and ExperimentForm presenters use the standard child-container + DiContainerProvider scoping. The ExperimentsEditor hub presenter, though, is an app-wide singleton (registered in Extension.tsx). Unlike a webhooks page — where one WebhookListView hosts the whole feature under a single provider — the experiments UI mounts in disjoint page-editor slots with no common React ancestor: the switcher/indicator in a top-bar Ui.TopBar.Action, the toolbar in a Ui.Content.Element, and the surface swap in PageEditor. They all have to observe the same selection/paused state, so a shared singleton is the only way to bridge them. It's initialised per page via presenter.init(page.id), and only one editor is open at a time, so it's effectively page-scoped. Happy to wrap the whole editor in an editor-level DiContainerProvider instead if you'd rather keep it uniform.

@SvenAlHamad SvenAlHamad added this to the 6.5.0 milestone Jul 16, 2026
@brunozoric
brunozoric changed the base branch from next to release/6.5.0 July 16, 2026 11:22
Resolve PageEditor.tsx conflict: adopt release's GetSettingsFeature-based
settings loading; keep the experiments PageEditorSurface/presenter/drawer
render (release's inline DocumentEditor + Default*Config already live in
PageEditorSurface).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SvenAlHamad
SvenAlHamad merged commit 7e2f6f5 into release/6.5.0 Jul 16, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants