Commit b422f5609dc for woocommerce
commit b422f5609dc8c92d8e5512c5e932499bce226063
Author: Cem Ünalan <raicem@users.noreply.github.com>
Date: Wed Oct 7 21:19:01 2026 +0300
Run the Marketplace product preview as an A/B test (#69141)
* WOOMKT-830: Run the marketplace product preview as an A/B test
* WOOMKT-830: Add changelog entry for the product preview A/B test
* Simplify the product preview experiment wiring
* WOOMKT-830: Address review feedback on the product preview A/B test
Stores without an assignment follow the product_previews setting again, so the preview can be turned on after the test without a release. The small cards in install modals stay out of the experiment, preview links are tagged before sanitizing, and protocol-relative WooCommerce.com links are tagged too.
* WOOMKT-830: Drop the cached preview variation when tracking is off
useExperiment keeps returning a cached assignment after a store turns
tracking off, so opted-out stores still got the preview and sent
utm_term to WooCommerce.com. They now follow the product_previews
setting like other stores without an assignment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/WOOMKT-830 b/plugins/woocommerce/changelog/WOOMKT-830
new file mode 100644
index 00000000000..c2f31c23800
--- /dev/null
+++ b/plugins/woocommerce/changelog/WOOMKT-830
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Show a product preview when clicking an extension in the in-app Marketplace, for stores in an A/B test.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx
index fbf93835341..d390ddb5bc9 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx
@@ -26,6 +26,10 @@ import {
import { appendURLParams } from '../../utils/functions';
import ProductPreviewModal from '../product-preview-modal/product-preview-modal';
import { MarketplaceContext } from '../../contexts/marketplace-context';
+import {
+ PRODUCT_PREVIEW_TREATMENT,
+ PRODUCT_PREVIEW_VARIATION_PARAM,
+} from '../../utils/product-preview-experiment';
export interface ProductCardProps {
type?: string;
product?: Product;
@@ -79,11 +83,17 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
const isTheme = type === ProductType.theme;
const isBusinessService = type === ProductType.businessService;
- const { iamSettings } = useContext( MarketplaceContext );
+ const { iamSettings, productPreviewVariation } =
+ useContext( MarketplaceContext );
+ // Themes, business services and the small cards in install modals never show the preview, so their clicks stay out of the experiment.
+ const canShowPreview = ! isTheme && ! isBusinessService && ! props.small;
+ const previewVariation = canShowPreview ? productPreviewVariation : null;
+ // Stores outside the experiment follow the WooCommerce.com setting.
const shouldShowPreview =
- iamSettings?.product_previews === 'modal' &&
- ! isTheme &&
- ! isBusinessService;
+ canShowPreview &&
+ ( previewVariation
+ ? previewVariation === PRODUCT_PREVIEW_TREATMENT
+ : iamSettings?.product_previews === 'modal' );
// Business service cards use a layout with no slot for the badge.
const showsQualityBadge =
! isLoading &&
@@ -185,12 +195,19 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
);
const productUrl = () => {
+ const params: Array< [ string, string ] > = [];
if ( query.ref ) {
- return appendURLParams( product.url, [
- [ 'utm_content', query.ref ],
+ params.push( [ 'utm_content', query.ref ] );
+ }
+ if ( previewVariation ) {
+ params.push( [
+ PRODUCT_PREVIEW_VARIATION_PARAM,
+ previewVariation,
] );
}
- return product.url;
+ return params.length
+ ? appendURLParams( product.url, params )
+ : product.url;
};
const handleCardClick = () => {
@@ -200,6 +217,7 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
vendor: product.vendorName,
product_type: type,
has_quality_badge: showsQualityBadge,
+ ...( previewVariation && { preview_variation: previewVariation } ),
} );
if ( shouldShowPreview ) {
@@ -431,6 +449,7 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
onClose={ handleModalClose }
productId={ product.id as number }
triggerRef={ linkRef }
+ variation={ previewVariation }
/>
) }
</>
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card.test.tsx
index faac2fdf821..24320f305a9 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card.test.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/test/product-card.test.tsx
@@ -32,7 +32,7 @@ jest.mock( '@woocommerce/data', () => ( {
/**
* Internal dependencies
*/
-import ProductCard from '../product-card';
+import ProductCard, { ProductCardProps } from '../product-card';
import { MarketplaceContext } from '../../../contexts/marketplace-context';
import { MarketplaceContextType } from '../../../contexts/types';
import {
@@ -51,6 +51,7 @@ const context = {
tooltip: 'Verified against WooCommerce standards.',
},
},
+ productPreviewVariation: null,
} as unknown as MarketplaceContextType;
const product: Product = {
@@ -76,7 +77,8 @@ const product: Product = {
function renderCard(
cardType: ProductCardType,
productOverrides: Partial< Product > = {},
- contextOverrides: Partial< MarketplaceContextType > = {}
+ contextOverrides: Partial< MarketplaceContextType > = {},
+ cardProps: Partial< ProductCardProps > = {}
) {
return render(
<MarketplaceContext.Provider
@@ -87,6 +89,7 @@ function renderCard(
product={ { ...product, ...productOverrides } }
cardType={ cardType }
tracksData={ { position: 1 } }
+ { ...cardProps }
/>
</MarketplaceContext.Provider>
);
@@ -290,16 +293,107 @@ describe( 'ProductCard click tracking', () => {
} );
it( 'records the click once when the card opens a preview modal', () => {
- clickCard(
- {},
- {
- iamSettings: {
- ...context.iamSettings,
- product_previews: 'modal',
- },
- }
- );
+ clickCard( {}, { productPreviewVariation: 'treatment' } );
expect( cardClickEvents() ).toHaveLength( 1 );
} );
} );
+
+describe( 'ProductCard product preview experiment', () => {
+ const productUrl = 'https://woocommerce.com/products/test-extension/';
+
+ beforeEach( () => {
+ jest.mocked( queueRecordEvent ).mockClear();
+ } );
+
+ it.each( [
+ {
+ name: 'opens the preview instead of the product page in the treatment arm',
+ variation: 'treatment',
+ opensProductPage: false,
+ tag: 'treatment',
+ },
+ {
+ name: 'opens the product page in the control arm',
+ variation: 'control',
+ opensProductPage: true,
+ tag: 'control',
+ },
+ {
+ name: 'leaves the card untagged until the assignment loads',
+ variation: null,
+ opensProductPage: true,
+ tag: undefined,
+ },
+ {
+ name: 'follows the WooCommerce.com setting when the store has no assignment',
+ variation: null,
+ productPreviews: 'modal' as const,
+ opensProductPage: false,
+ tag: undefined,
+ },
+ {
+ name: 'ignores the WooCommerce.com setting in the control arm',
+ variation: 'control',
+ productPreviews: 'modal' as const,
+ opensProductPage: true,
+ tag: 'control',
+ },
+ {
+ name: 'keeps business service cards out of the experiment',
+ variation: 'treatment',
+ type: ProductType.businessService,
+ opensProductPage: true,
+ tag: undefined,
+ },
+ {
+ name: 'keeps theme cards out of the experiment',
+ variation: 'treatment',
+ cardProps: { type: ProductType.theme },
+ opensProductPage: true,
+ tag: undefined,
+ },
+ {
+ name: 'keeps the small cards in install modals out of the experiment',
+ variation: 'treatment',
+ cardProps: { small: true },
+ opensProductPage: true,
+ tag: undefined,
+ },
+ ] )(
+ '$name',
+ ( {
+ variation,
+ productPreviews,
+ type = ProductType.extension,
+ cardProps = {},
+ opensProductPage,
+ tag,
+ } ) => {
+ const view = renderCard(
+ ProductCardType.regular,
+ { url: productUrl, type },
+ {
+ productPreviewVariation: variation,
+ iamSettings: {
+ ...context.iamSettings,
+ product_previews: productPreviews,
+ },
+ },
+ cardProps
+ );
+ const link = view.getByRole( 'link' );
+
+ // fireEvent returns false when the click's default action was prevented.
+ expect( fireEvent.click( link ) ).toBe( opensProductPage );
+ expect(
+ jest.mocked( queueRecordEvent ).mock.calls[ 0 ][ 1 ]
+ ?.preview_variation
+ ).toBe( tag );
+ expect( link ).toHaveAttribute(
+ 'href',
+ tag ? `${ productUrl }?utm_term=${ tag }` : productUrl
+ );
+ }
+ );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-preview-modal/product-preview-modal.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-preview-modal/product-preview-modal.tsx
index e24fc2c5128..a9c5342cd0e 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-preview-modal/product-preview-modal.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-preview-modal/product-preview-modal.tsx
@@ -10,6 +10,7 @@ import { Spinner } from '@woocommerce/components';
* Internal dependencies
*/
import { fetchProductPreview } from '../../utils/functions';
+import { addVariationToPreviewLinks } from '../../utils/product-preview-experiment';
import sanitizeHtmlExtended from '~/lib/sanitize-html/sanitize-html-extended.js';
import sanitizeHtmlConfig from './product-preview-sanitize-html-config';
import './product-preview-modal.scss';
@@ -20,6 +21,7 @@ interface ProductPreviewModalProps {
productIcon: string;
productId: number;
triggerRef: React.RefObject< HTMLAnchorElement | null >;
+ variation?: string | null;
onOpen?: () => void;
onClose?: ( closeType?: string ) => void;
}
@@ -30,6 +32,7 @@ export default function ProductPreviewModal( {
productIcon,
productId,
triggerRef,
+ variation,
onOpen,
onClose,
}: ProductPreviewModalProps ) {
@@ -99,8 +102,11 @@ export default function ProductPreviewModal( {
throw new Error( 'Invalid preview data structure' );
}
+ const previewHtml = variation
+ ? addVariationToPreviewLinks( previewData.html, variation )
+ : previewData.html;
const sanitizedHtmlObj = sanitizeHtmlExtended(
- previewData.html,
+ previewHtml,
sanitizeHtmlConfig
) as { __html?: string };
const sanitizedHtml = sanitizedHtmlObj?.__html ?? '';
@@ -123,7 +129,7 @@ export default function ProductPreviewModal( {
if ( onOpen ) {
onOpen();
}
- }, [ onOpen, productId ] );
+ }, [ onOpen, productId, variation ] );
const productHeader = (
<div className="woocommerce-marketplace__product-preview-modal__header">
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx b/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx
index 9865c0f9de3..12d2018e27a 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx
@@ -7,6 +7,7 @@ import {
useCallback,
createContext,
} from '@wordpress/element';
+import { useExperiment } from '@woocommerce/explat';
/**
* Internal dependencies
@@ -18,6 +19,7 @@ import {
MARKETPLACE_HOST,
MARKETPLACE_IAM_SETTINGS_API_PATH,
} from '../components/constants';
+import { PRODUCT_PREVIEW_EXPERIMENT_NAME } from '../utils/product-preview-experiment';
// Create storage utils with 24h expiration
const iamSettingsStorage = createStorageUtils< {
@@ -39,6 +41,7 @@ export const MarketplaceContext = createContext< MarketplaceContextType >( {
},
setSearchResultsCount: () => {},
iamSettings: {},
+ productPreviewVariation: null,
} );
export function MarketplaceContextProvider( props: {
@@ -47,6 +50,14 @@ export function MarketplaceContextProvider( props: {
const [ isLoading, setIsLoading ] = useState( true );
const [ selectedTab, setSelectedTab ] = useState( '' );
const [ iamSettings, setIamSettings ] = useState( {} );
+ // Loaded up front so product cards already know whether to open the preview modal when clicked.
+ const [ , productPreviewAssignment ] = useExperiment(
+ PRODUCT_PREVIEW_EXPERIMENT_NAME
+ );
+ // useExperiment keeps returning a cached assignment after tracking is turned off, so drop it here.
+ const productPreviewVariation = window.wcTracks?.isEnabled
+ ? productPreviewAssignment?.variationName ?? null
+ : null;
const [ installedPlugins, setInstalledPlugins ] = useState< string[] >(
[]
);
@@ -136,6 +147,7 @@ export function MarketplaceContextProvider( props: {
searchResultsCount,
setSearchResultsCount,
iamSettings,
+ productPreviewVariation,
};
return (
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/test/marketplace-context.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/contexts/test/marketplace-context.test.tsx
new file mode 100644
index 00000000000..3203b1071aa
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/test/marketplace-context.test.tsx
@@ -0,0 +1,59 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import { useContext } from '@wordpress/element';
+import { useExperiment } from '@woocommerce/explat';
+
+jest.mock( '@woocommerce/explat', () => ( {
+ useExperiment: jest.fn(),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import {
+ MarketplaceContext,
+ MarketplaceContextProvider,
+} from '../marketplace-context';
+
+function VariationProbe() {
+ const { productPreviewVariation } = useContext( MarketplaceContext );
+ return <span>{ productPreviewVariation ?? 'none' }</span>;
+}
+
+describe( 'MarketplaceContextProvider', () => {
+ const originalTracks = window.wcTracks;
+
+ beforeEach( () => {
+ ( useExperiment as jest.Mock ).mockReturnValue( [
+ false,
+ { variationName: 'treatment' },
+ ] );
+ global.fetch = jest.fn( () => new Promise( () => {} ) ) as jest.Mock;
+ } );
+
+ afterEach( () => {
+ window.wcTracks = originalTracks;
+ } );
+
+ it( 'passes the assigned variation when tracking is on', () => {
+ window.wcTracks = { ...originalTracks, isEnabled: true };
+ const { getByText } = render(
+ <MarketplaceContextProvider>
+ <VariationProbe />
+ </MarketplaceContextProvider>
+ );
+ expect( getByText( 'treatment' ) ).toBeInTheDocument();
+ } );
+
+ it( 'ignores a cached variation once tracking is turned off', () => {
+ window.wcTracks = { ...originalTracks, isEnabled: false };
+ const { getByText } = render(
+ <MarketplaceContextProvider>
+ <VariationProbe />
+ </MarketplaceContextProvider>
+ );
+ expect( getByText( 'none' ) ).toBeInTheDocument();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts b/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
index 3e2560eb0f4..818b69bdc86 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
@@ -41,6 +41,11 @@ export type MarketplaceContextType = {
docs_url?: string;
};
};
+ /**
+ * Variation of the product preview experiment, or null when it hasn't
+ * loaded or the store isn't in the experiment.
+ */
+ productPreviewVariation: string | null;
};
export type SubscriptionsContextType = {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/product-preview-experiment.ts b/plugins/woocommerce/client/admin/client/marketplace/utils/product-preview-experiment.ts
new file mode 100644
index 00000000000..79044a63f5b
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/product-preview-experiment.ts
@@ -0,0 +1,51 @@
+/**
+ * Internal dependencies
+ */
+import { MARKETPLACE_HOST } from '../components/constants';
+
+export const PRODUCT_PREVIEW_EXPERIMENT_NAME =
+ 'woocommerce_marketplace_product_preview_202610';
+
+export const PRODUCT_PREVIEW_TREATMENT = 'treatment';
+
+/**
+ * WooCommerce.com order attribution stores this parameter on orders, so
+ * purchases can be split by variation.
+ */
+export const PRODUCT_PREVIEW_VARIATION_PARAM = 'utm_term';
+
+/**
+ * Adds the variation to the WooCommerce.com links in the preview HTML, such as
+ * "Buy now" and "See more". Other links are left as they are.
+ */
+export function addVariationToPreviewLinks(
+ html: string,
+ variation: string
+): string {
+ const { hostname: marketplaceHostname } = new URL( MARKETPLACE_HOST );
+ const template = document.createElement( 'template' );
+ template.innerHTML = html;
+
+ template.content
+ .querySelectorAll< HTMLAnchorElement >( 'a[href]' )
+ .forEach( ( link ) => {
+ let url: URL;
+ try {
+ url = new URL(
+ link.getAttribute( 'href' ) ?? '',
+ document.baseURI
+ );
+ } catch {
+ return;
+ }
+
+ if ( url.hostname !== marketplaceHostname ) {
+ return;
+ }
+
+ url.searchParams.set( PRODUCT_PREVIEW_VARIATION_PARAM, variation );
+ link.setAttribute( 'href', url.toString() );
+ } );
+
+ return template.innerHTML;
+}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/test/product-preview-experiment.test.ts b/plugins/woocommerce/client/admin/client/marketplace/utils/test/product-preview-experiment.test.ts
new file mode 100644
index 00000000000..3a1abe955b0
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/test/product-preview-experiment.test.ts
@@ -0,0 +1,53 @@
+/**
+ * Internal dependencies
+ */
+import { addVariationToPreviewLinks } from '../product-preview-experiment';
+
+describe( 'addVariationToPreviewLinks', () => {
+ function hrefs( html: string ) {
+ const template = document.createElement( 'template' );
+ template.innerHTML = html;
+ return Array.from( template.content.querySelectorAll( 'a' ) ).map(
+ ( link ) => link.getAttribute( 'href' )
+ );
+ }
+
+ it( 'adds the variation to WooCommerce.com links and keeps their parameters', () => {
+ const html = addVariationToPreviewLinks(
+ '<p>Intro</p>' +
+ '<a href="https://woocommerce.com/cart/?add-to-cart=1&utm_source=previewscreen" data-iam-tracks="buy_now">Buy now</a>' +
+ '<a href="https://woocommerce.com/products/test/?utm_source=previewscreen">See more</a>',
+ 'treatment'
+ );
+
+ expect( hrefs( html ) ).toEqual( [
+ 'https://woocommerce.com/cart/?add-to-cart=1&utm_source=previewscreen&utm_term=treatment',
+ 'https://woocommerce.com/products/test/?utm_source=previewscreen&utm_term=treatment',
+ ] );
+ expect( html ).toContain( '<p>Intro</p>' );
+ expect( html ).toContain( 'data-iam-tracks="buy_now"' );
+ } );
+
+ it( 'adds the variation to protocol-relative WooCommerce.com links', () => {
+ const html = addVariationToPreviewLinks(
+ '<a href="//woocommerce.com/cart/">Buy now</a>',
+ 'treatment'
+ );
+
+ expect( hrefs( html ) ).toEqual( [
+ `${ window.location.protocol }//woocommerce.com/cart/?utm_term=treatment`,
+ ] );
+ } );
+
+ it( 'leaves links to other sites and relative links unchanged', () => {
+ const html = addVariationToPreviewLinks(
+ '<a href="https://example.com/docs/">Docs</a><a href="#details">Details</a>',
+ 'treatment'
+ );
+
+ expect( hrefs( html ) ).toEqual( [
+ 'https://example.com/docs/',
+ '#details',
+ ] );
+ } );
+} );