Skip to content

feat(core): A/B test variant branch via proxy rewrite#3378

Draft
gabpaladino wants to merge 2 commits into
devfrom
feat/ab-test-variant-branch
Draft

feat(core): A/B test variant branch via proxy rewrite#3378
gabpaladino wants to merge 2 commits into
devfrom
feat/ab-test-variant-branch

Conversation

@gabpaladino

Copy link
Copy Markdown
Member

What

Adds A/B test support driven by a __variant querystring param. The Next.js proxy (proxy.ts — the Next 16 convention that replaces middleware.ts) reads __variant and rewrites the request to an internal /_variant/[branchId]/... route, so getStaticProps receives the branchId via params and forwards it to ContentService for all CP calls.

Spec/plan/tasks live in the separate faststore-dx-spec-kit repo (feature 002-ab-test-variant-branch) and were updated to reflect proxy.ts (Next 16) instead of middleware.ts.

How

  • proxy.ts — composes resolveVariantRewrite(request.nextUrl) at the top of proxy(), before the existing localization rewrites; reuses the existing matcher.
  • utils/variant.ts — pure, unit-tested resolveVariantRewrite(url) + VARIANT_QUERY_PARAM / VARIANT_PATH_PREFIX constants.
  • server/contentbranchId? on ContentRequestContext, getVariantBranchId(params), and buildCmsOptions maps branchIdcmsOptions.versionId without enabling preview mode (precedence: branchId > previewData.versionId).
  • pages/_variant/[branchId]/* — thin variant-aware wrappers (home, PDP, PLP/landing) with fallback: 'blocking' that delegate to existing page logic.
  • components/ExperimentDataLayer — client-side: reads the vtex_exp_variant cookie and pushes { experiment_id, variant_id } to window.dataLayer.

Why this preserves ISR

getStaticProps has no access to querystring/headers. Encoding the variant in the path (via proxy rewrite) keeps each /_variant/[branchId]/... as a distinct ISR entry — no getServerSideProps, no preview mode. Original pages stay untouched, so non-experiment traffic is unaffected.

Testing

  • Unit (vitest): resolveVariantRewrite, getVariantBranchId, buildCmsOptions with branchId.
  • Integration: variant pages call ContentService with the correct branchId across content types.
  • Browser test: ExperimentDataLayer cookie → dataLayer behavior.
  • ⏳ Pending: QA-tenant propagation checklist + deploy smoke (Phases 4 & 6).

Scope note

PoC scope — single experiment, hardcoded experiment_id. Unrelated working-tree tooling churn (tsconfig.json, pnpm-workspace.yaml, next-env.d.ts, build artifacts) was intentionally left out of this branch.

🤖 Generated with Claude Code

Read `__variant` from the querystring in the Next.js proxy (proxy.ts) and
rewrite to internal `/_variant/[branchId]/...` routes so getStaticProps
receives the branchId via params and passes it to ContentService. ISR is
preserved — each branchId is a distinct cache entry — and original pages
stay untouched.

- proxy.ts: compose resolveVariantRewrite before localization rewrites
- utils/variant.ts: pure resolveVariantRewrite(url) + constants
- server/content: branchId on ContentRequestContext, getVariantBranchId,
  map branchId to cmsOptions.versionId without enabling preview mode
- pages/_variant/[branchId]/*: thin variant-aware home/PDP/PLP wrappers
- components/ExperimentDataLayer: push experiment context to dataLayer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1b66741d-5a3d-4041-a79b-287559646621

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ab-test-variant-branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@codesandbox-ci

codesandbox-ci Bot commented Jun 2, 2026

Copy link
Copy Markdown

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

@renatomaurovtex renatomaurovtex 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.

Reviewed the diff against the @faststore/core boundaries and the ISR/proxy design. The ISR-preserving approach (path-encoded variant + fallback: 'blocking', untouched original pages) is sound, tests are well-placed, and the tooling churn was correctly kept out of the branch. Two things I'd resolve before merge — one security/authorization, one maintainability — plus a couple of nits.


🟠 [security] src/utils/variant.ts__variant is fully attacker-controlled and unvalidated

resolveVariantRewrite reflects the raw querystring value straight into the rewrite path, the ISR cache key, and (downstream) the CP versionId. Since __variant is a public, user-supplied param, this opens three concerns:

  1. Unpublished/branch content disclosure. branchIdcmsOptions.versionId resolves CP content from an arbitrary branch without preview mode (by design, to keep ISR). But "no preview mode" also means no auth gate — any visitor who knows/guesses a branchId can render draft/version content that previously required authenticated preview. Is that an accepted trade-off for the PoC, or should the branch id be validated against an allowlist (e.g. the active experiment's variants) before it's honored?
  2. Cache-fill / SSR amplification. Each distinct __variant value mints a new /_variant/<x>/... ISR entry with fallback: 'blocking', so unbounded values let an attacker force unbounded on-demand renders. An allowlist/charset bound caps this.
  3. Path normalization. Assigning to URL.pathname normalizes .. segments, so a crafted value can escape the /_variant/ prefix.

Minimum fix — reject anything that isn't a plain branch token:

+const VALID_VARIANT = /^[a-zA-Z0-9_-]+$/
+
 export function resolveVariantRewrite(url: URL): URL | null {
   const variant = url.searchParams.get(VARIANT_QUERY_PARAM)

-  if (!variant) {
+  if (!variant || !VALID_VARIANT.test(variant)) {
     return null
   }

Flagging for human security review since it touches the authorization boundary for non-production content.


🟡 [code quality] _variant/[branchId]/[slug]/p.tsx & _variant/[branchId]/[...slug].tsx — duplicated GraphQL operations

ServerProductQuery and ServerCollectionPageQuery are copy-pasted byte-identical from the original pages, and the variant pages import the originals' generated types. Today the client-preset dedupes by document string so pnpm codegen is fine — but the moment either copy drifts, codegen breaks with Not all operations have an unique name (or the reused generated types silently go stale). Extract the query into a shared module and import it from both the original and variant page instead of maintaining two copies.


💬 [performance] 3 new _variant page entrypoints

The wrappers re-export the original components, but each is still a distinct route/bundle. Acceptable for a PoC; just confirm pnpm size shows no regression before merge (perf is NON-NEGOTIABLE here).

💬 [nit] src/server/content/types.ts — stale "middleware" wording

branchId doc says "Sourced from the __variant querystring via middleware." The entrypoint is now proxy.ts (Next 16). Quick wording fix to match the rest of the PR.

💬 [nit] ExperimentDataLayer/index.tsx — redundant typeof window guard

The check inside useEffect is dead code — effects only run client-side. Harmless, drop it if you want.


Verdict: Changes requested

Blocking (🔴/🟠):

  • 🟠 Validate/allowlist __variant in resolveVariantRewrite — unauthenticated branch-content access, cache-fill, and path normalization. Needs human security sign-off on the authorization trade-off.

Non-blocking (🟡/💬):

  • 🟡 De-duplicate the copy-pasted ServerProductQuery / ServerCollectionPageQuery operations (latent codegen break on drift).
  • 💬 Confirm pnpm size for the new variant routes.
  • 💬 Stale "middleware" wording in types.ts; redundant typeof window guard in ExperimentDataLayer.

Checks to confirm before merge: pnpm lint · build · pnpm codegen · pnpm turbo run test --filter=@faststore/core · pnpm size

@pkg-pr-new

pkg-pr-new Bot commented Jun 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

@faststore/api

npm i https://pkg.pr.new/vtex/faststore/@faststore/api@f3bb6ad

@faststore/cli

npm i https://pkg.pr.new/vtex/faststore/@faststore/cli@f3bb6ad

@faststore/components

npm i https://pkg.pr.new/vtex/faststore/@faststore/components@f3bb6ad

@faststore/core

npm i https://pkg.pr.new/vtex/faststore/@faststore/core@f3bb6ad

@faststore/diagnostics

npm i https://pkg.pr.new/vtex/faststore/@faststore/diagnostics@f3bb6ad

@faststore/lighthouse

npm i https://pkg.pr.new/vtex/faststore/@faststore/lighthouse@f3bb6ad

@faststore/sdk

npm i https://pkg.pr.new/vtex/faststore/@faststore/sdk@f3bb6ad

@faststore/ui

npm i https://pkg.pr.new/vtex/faststore/@faststore/ui@f3bb6ad

commit: f3bb6ad

@sonar-workflows

Copy link
Copy Markdown

Failed Quality Gate failed

  • 7 New Issues (is greater than 0)
  • 28.20% Coverage on New Code (is less than 80.00%)
  • 35.73% Duplicated Lines (%) on New Code (is greater than 3.00%)

Project ID: vtex_faststore_f0a862d5-9557-49f9-8d09-de40caa76622

View in SonarQube

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.

2 participants