Commit ef333b01b3c for woocommerce

commit ef333b01b3cd2b75dc09628398d3a227d5a63526
Author: Jan Lysý <lysyjan@users.noreply.github.com>
Date:   Thu Mar 12 14:07:21 2026 +0100

    Add placeholder products in email editor for product collection blocks (#63612)

    * Show product collection preview button on hover instead of only on selection

    Remove the isSelected condition from the preview button rendering so it
    is always present in the DOM when preview mode is active. Use CSS opacity
    with a transition to show it on hover, selection, or child selection,
    providing a smoother and more discoverable UX.

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

    * Add email editor preview state for best-sellers and new-arrivals collections

    Register setPreviewState callbacks that detect the email editor context
    via the email-editor/editor store and activate preview mode with a
    descriptive message. When not in the email editor, the callback
    replicates the generic archive-template fallback behavior so the
    existing preview logic is preserved.

    On the PHP side, register preview_query handlers that return recent
    products as fallback data for these collections.

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

    * Show placeholder product cards in preview mode when store has no products

    When the product template is in preview mode and the query returns zero
    products, render placeholder cards with an image icon, product name, and
    a currency-aware price instead of the "No products to display" message.
    Grid layout classes are also applied to the placeholder state so cards
    respect the configured column count.

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

    * Use emailContent for pattern insertion in the email editor

    Add a typed emailBlocks lazy getter to the enhanced pattern object that
    parses the emailContent field (dynamic product-collection blocks) when
    available. The template selection modal now uses emailBlocks with a
    fallback to blocks, ensuring patterns insert dynamic WooCommerce blocks
    into the editor instead of static placeholder HTML.

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

    * Add changelog entries for email editor and WooCommerce plugin

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

    * Add clarifying comments for emailContent property and store detection

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

    * Revert "Show product collection preview button on hover instead of only on selection"

    This reverts commit db949373e89e1a1b51362a3582d5879a634fda17.

    * Show preview button always in email editor, only on selection elsewhere

    In the email editor, the preview button is always visible so users
    know the products are placeholders. In the regular editor, it only
    shows when the block is selected (preserving existing test behavior).

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

    * Fix lint: move @woocommerce/email-editor to external dependencies

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

    * Replace hardcoded placeholder HTML with real block rendering via entity injection

    Instead of rendering static HTML placeholders when the store has no products,
    inject placeholder product entities into WordPress core-data stores and render
    using the actual ProductContentComponent. This ensures placeholders match real
    product rendering exactly, including price, image, button, and title blocks.

    Wraps placeholders with ProductDataContextProvider so child blocks using the
    withProductDataContext HOC (price, image, button) access product data through
    React context instead of fetching from the Store API.

    Also fixes noop_build_query to return empty array per CodeRabbit review.

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

    ---------

    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

diff --git a/packages/js/email-editor/changelog/add-email-content-pattern-insertion b/packages/js/email-editor/changelog/add-email-content-pattern-insertion
new file mode 100644
index 00000000000..9fef1b27818
--- /dev/null
+++ b/packages/js/email-editor/changelog/add-email-content-pattern-insertion
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add emailBlocks getter to pattern enhancement for inserting dynamic email content blocks
diff --git a/packages/js/email-editor/src/hooks/use-preview-templates.ts b/packages/js/email-editor/src/hooks/use-preview-templates.ts
index 01d2982ce37..de75d54d8a9 100644
--- a/packages/js/email-editor/src/hooks/use-preview-templates.ts
+++ b/packages/js/email-editor/src/hooks/use-preview-templates.ts
@@ -154,7 +154,8 @@ export function usePreviewTemplates(
 						slug: template.slug,
 						// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
 						previewContentParsed: parsedTemplate,
-						emailParsed: contentPattern.blocks,
+						emailParsed:
+							contentPattern.emailBlocks ?? contentPattern.blocks,
 						template,
 						category: contentPattern.categories?.[ 0 ],
 						type: template.type,
diff --git a/packages/js/email-editor/src/store/selectors.ts b/packages/js/email-editor/src/store/selectors.ts
index e2967afcabf..2948f72aae5 100644
--- a/packages/js/email-editor/src/store/selectors.ts
+++ b/packages/js/email-editor/src/store/selectors.ts
@@ -33,8 +33,25 @@ function getContentFromEntity( entity ): string {
 	return '';
 }

-const patternsWithParsedBlocks = new WeakMap();
-function enhancePatternWithParsedBlocks( pattern ) {
+type PatternWithContent = {
+	content: string;
+	emailContent?: string;
+	categories?: string[];
+	[ key: string ]: unknown;
+};
+
+type EnhancedPattern = PatternWithContent & {
+	readonly blocks: BlockInstance[];
+	readonly emailBlocks: BlockInstance[] | null;
+};
+
+const patternsWithParsedBlocks = new WeakMap<
+	PatternWithContent,
+	EnhancedPattern
+>();
+function enhancePatternWithParsedBlocks(
+	pattern: PatternWithContent
+): EnhancedPattern {
 	let enhancedPattern = patternsWithParsedBlocks.get( pattern );
 	if ( ! enhancedPattern ) {
 		enhancedPattern = {
@@ -42,6 +59,15 @@ function enhancePatternWithParsedBlocks( pattern ) {
 			get blocks() {
 				return parse( pattern.content );
 			},
+			// emailContent is an optional property that integrations (e.g. MailPoet)
+			// may add to patterns via REST API filters. It contains dynamic blocks
+			// (e.g. product-collection) for editor insertion, while `content` holds
+			// static HTML for template picker previews.
+			get emailBlocks() {
+				return pattern.emailContent
+					? parse( pattern.emailContent )
+					: null;
+			},
 		};
 		patternsWithParsedBlocks.set( pattern, enhancedPattern );
 	}
diff --git a/plugins/woocommerce/changelog/add-placeholder-products-email-editor b/plugins/woocommerce/changelog/add-placeholder-products-email-editor
new file mode 100644
index 00000000000..dca8462e978
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-placeholder-products-email-editor
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Show preview products and placeholder cards in the email editor for product collection blocks when the store has no products
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx
index 87b230b8b18..95cab5cd23d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx
@@ -13,6 +13,7 @@ import { Icon, chartBar } from '@wordpress/icons';
  */
 import { INNER_BLOCKS_PRODUCT_TEMPLATE } from '../constants';
 import { CoreCollectionNames, CoreFilterNames } from '../types';
+import { setEmailEditorPreviewState } from '../utils';

 const collection = {
 	name: CoreCollectionNames.BEST_SELLERS,
@@ -57,4 +58,7 @@ export default {
 	...collection,
 	attributes,
 	innerBlocks,
+	preview: {
+		setPreviewState: setEmailEditorPreviewState,
+	},
 };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx
index aa75a72cb5a..5e2b84bc118 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx
@@ -17,6 +17,7 @@ import {
 	CoreFilterNames,
 	ETimeFrameOperator,
 } from '../types';
+import { setEmailEditorPreviewState } from '../utils';

 const collection = {
 	name: CoreCollectionNames.NEW_ARRIVALS,
@@ -65,4 +66,7 @@ export default {
 	...collection,
 	attributes,
 	innerBlocks,
+	preview: {
+		setPreviewState: setEmailEditorPreviewState,
+	},
 };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx
index 140bb0b6f64..4311aaf913c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx
@@ -11,6 +11,7 @@ import { useEffect, useRef, useMemo } from '@wordpress/element';
 import { Button } from '@wordpress/components';
 import { useSelect, useDispatch } from '@wordpress/data';
 import fastDeepEqual from 'fast-deep-equal/es6';
+import { useIsEmailEditor } from '@woocommerce/email-editor';

 /**
  * Internal dependencies
@@ -79,6 +80,8 @@ const ProductCollectionContent = ( {
 		isUsingReferencePreviewMode,
 	} = props;

+	const isEmailEditor = useIsEmailEditor();
+
 	useSetPreviewState( {
 		setPreviewState,
 		setAttributes,
@@ -164,7 +167,7 @@ const ProductCollectionContent = ( {
 	return (
 		<div { ...blockProps }>
 			{ attributes.__privatePreviewState?.isPreview &&
-				props.isSelected && (
+				( isEmailEditor || props.isSelected ) && (
 					<Button
 						variant="primary"
 						size="small"
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx
index 460b92331dd..661f3df7cf9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx
@@ -523,6 +523,56 @@ export const getDefaultProductCollection = () =>
 		createBlocksFromInnerBlocksTemplate( INNER_BLOCKS_TEMPLATE )
 	);

+/**
+ * Sets preview state for product collections in the email editor context.
+ * When the email editor store is present, activates preview mode so that
+ * the collection shows sample products instead of "No products to display".
+ *
+ * When NOT in the email editor, explicitly resets preview state so the
+ * default archive-template fallback behavior in useSetPreviewState is
+ * preserved (providing setPreviewState suppresses the generic fallback).
+ */
+export const setEmailEditorPreviewState: SetPreviewState = ( {
+	setState,
+	location,
+	attributes,
+} ) => {
+	let isEmailEditor = false;
+	try {
+		// Detect the email editor by checking for its store.
+		// Depending on @wordpress/data version, select() may throw
+		// or return undefined for unregistered stores — handle both.
+		isEmailEditor = !! select( 'email-editor/editor' );
+	} catch {
+		// Not in email editor context.
+	}
+
+	if ( isEmailEditor ) {
+		setState( {
+			isPreview: true,
+			previewMessage: __(
+				'Sample products shown for preview. Actual products will be based on store inventory.',
+				'woocommerce'
+			),
+		} );
+	} else {
+		// Replicate the generic archive-template fallback: show preview
+		// label only when inheriting query in a generic archive template.
+		const isGenericArchiveTemplate =
+			location.type === LocationType.Archive &&
+			! location.sourceData?.termId;
+		setState( {
+			isPreview: isGenericArchiveTemplate
+				? !! attributes?.query?.inherit
+				: false,
+			previewMessage: __(
+				'Actual products will vary depending on the page being viewed.',
+				'woocommerce'
+			),
+		} );
+	}
+};
+
 export const useGetProduct = ( productId: number | undefined ) => {
 	const [ product, setProduct ] = useState< ProductResponseItem | null >(
 		null
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/edit.tsx
index 4722a30f9c0..4b53a495a7e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/edit.tsx
@@ -32,6 +32,7 @@ import {
 	parseTemplateSlug,
 } from './utils';
 import { getDefaultStockStatuses } from '../product-collection/constants';
+import { usePlaceholderProducts } from './use-placeholder-products';

 const DEFAULT_QUERY_CONTEXT_ATTRIBUTES = [ 'collection' ];

@@ -45,9 +46,9 @@ const ProductTemplateInnerBlocks = () => {

 type ProductTemplateBlockPreviewProps = {
 	blocks: object[];
-	blockContextId: string;
+	blockContextId: string | number;
 	isHidden: boolean;
-	setActiveBlockContextId: ( blockContextId: string ) => void;
+	setActiveBlockContextId: ( blockContextId: string | number ) => void;
 };

 const ProductTemplateBlockPreview = ( {
@@ -87,14 +88,14 @@ const ProductTemplateBlockPreview = ( {
 const MemoizedProductTemplateBlockPreview = memo( ProductTemplateBlockPreview );

 type ProductContentProps = {
-	attributes: { productId: string };
+	attributes: { productId: string | number };
 	displayTemplate: boolean;
 	blocks: BlockInstance[];
 	blockContext: {
 		postType: string;
-		postId: string;
+		postId: string | number;
 	};
-	setActiveBlockContextId: ( id: string ) => void;
+	setActiveBlockContextId: ( id: string | number ) => void;
 };

 const ProductContent = ( {
@@ -234,8 +235,9 @@ const ProductTemplateEdit = (
 	const location = useGetLocation( props.context, props.clientId );

 	const [ { page } ] = queryContext;
-	const [ activeBlockContextId, setActiveBlockContextId ] =
-		useState< string >();
+	const [ activeBlockContextId, setActiveBlockContextId ] = useState<
+		string | number
+	>();
 	const postType = 'product';
 	const loopShopPerPage = getSettingWithCoercion(
 		'loopShopPerPage',
@@ -396,8 +398,25 @@ const ProductTemplateEdit = (
 	const hasLayoutFlex = layoutType === 'flex' && columns > 1;
 	let customClassName = '';

-	// We don't want to apply layout styles if there's no products.
-	if ( products && products.length && hasLayoutFlex ) {
+	const isPreviewWithNoProducts =
+		!! __privateProductCollectionPreviewState?.isPreview &&
+		!! products &&
+		! products.length;
+
+	const {
+		blockContexts: placeholderContexts,
+		placeholderProductMap,
+		isReady: placeholdersReady,
+	} = usePlaceholderProducts( {
+		isPreviewWithNoProducts,
+		count: perPage ?? 4,
+	} );
+
+	// Apply layout styles when products are present or when showing preview placeholders.
+	if (
+		( ( products && products.length ) || isPreviewWithNoProducts ) &&
+		hasLayoutFlex
+	) {
 		const dynamicGrid = `wc-block-product-template__responsive columns-${ columns }`;
 		const staticGrid = `is-flex-container columns-${ columns }`;

@@ -413,6 +432,10 @@ const ProductTemplateEdit = (
 		),
 	} );

+	const ProductContentComponent = isInSingleProductBlock
+		? ProductContentWithProduct
+		: ProductContent;
+
 	if ( ! products ) {
 		return (
 			<p { ...blockProps }>
@@ -422,6 +445,57 @@ const ProductTemplateEdit = (
 	}

 	if ( ! products.length ) {
+		if (
+			isPreviewWithNoProducts &&
+			placeholdersReady &&
+			placeholderContexts
+		) {
+			return (
+				<ul { ...blockProps }>
+					{ placeholderContexts.map( ( blockContext ) => {
+						const displayTemplate =
+							blockContext.postId ===
+							( activeBlockContextId ||
+								placeholderContexts[ 0 ]?.postId );
+
+						return (
+							<ProductDataContextProvider
+								key={ blockContext.postId }
+								product={
+									placeholderProductMap.get(
+										blockContext.postId as number
+									) ?? null
+								}
+								isLoading={ false }
+							>
+								{ /* Always use ProductContent for placeholders to avoid
+								   withProduct HOC making failing API calls for negative IDs. */ }
+								<ProductContent
+									attributes={ {
+										productId: blockContext.postId,
+									} }
+									blocks={ blocks }
+									displayTemplate={ displayTemplate }
+									blockContext={ blockContext }
+									setActiveBlockContextId={
+										setActiveBlockContextId
+									}
+								/>
+							</ProductDataContextProvider>
+						);
+					} ) }
+				</ul>
+			);
+		}
+
+		if ( isPreviewWithNoProducts && ! placeholdersReady ) {
+			return (
+				<p { ...blockProps }>
+					<Spinner className="wc-block-product-template__spinner" />
+				</p>
+			);
+		}
+
 		return (
 			<p { ...blockProps }>
 				{ ' ' }
@@ -433,10 +507,6 @@ const ProductTemplateEdit = (
 		);
 	}

-	const ProductContentComponent = isInSingleProductBlock
-		? ProductContentWithProduct
-		: ProductContent;
-
 	// To avoid flicker when switching active block contexts, a preview is rendered
 	// for each block context, but the preview for the active block context is hidden.
 	// This ensures that when it is displayed again, the cached rendering of the
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/use-placeholder-products.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/use-placeholder-products.ts
new file mode 100644
index 00000000000..2cde9354617
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/use-placeholder-products.ts
@@ -0,0 +1,265 @@
+/**
+ * External dependencies
+ */
+import { useEffect, useMemo, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { dispatch } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { SITE_CURRENCY } from '@woocommerce/settings';
+import type { ProductResponseItem } from '@woocommerce/types';
+
+/**
+ * Placeholder product IDs use large negative numbers to avoid
+ * collisions with real product IDs.
+ */
+const PLACEHOLDER_ID_BASE = -999000;
+const PLACEHOLDER_PRICE = '9.99';
+
+/**
+ * Returns the placeholder price converted to minor currency units.
+ */
+const getPlaceholderPriceMinorUnits = () =>
+	String(
+		Math.round(
+			parseFloat( PLACEHOLDER_PRICE ) *
+				Math.pow( 10, SITE_CURRENCY.minorUnit )
+		)
+	);
+
+/**
+ * Creates a placeholder product in Store API format (ProductResponseItem).
+ * Used by ProductDataContextProvider so child blocks (price, image, button)
+ * can render previews without fetching from the Store API.
+ */
+const createPlaceholderResponseItem = ( id: number ): ProductResponseItem => {
+	const placeholderName = __( 'Product name', 'woocommerce' );
+	const priceInMinorUnits = getPlaceholderPriceMinorUnits();
+
+	return {
+		id,
+		name: placeholderName,
+		parent: 0,
+		type: 'simple',
+		variation: '',
+		permalink: '',
+		sku: '',
+		slug: `placeholder-product-${ id }`,
+		short_description: '',
+		description: '',
+		on_sale: false,
+		prices: {
+			currency_code: SITE_CURRENCY.code,
+			currency_symbol: SITE_CURRENCY.symbol,
+			currency_minor_unit: SITE_CURRENCY.minorUnit,
+			currency_decimal_separator: SITE_CURRENCY.decimalSeparator,
+			currency_thousand_separator: SITE_CURRENCY.thousandSeparator,
+			currency_prefix: SITE_CURRENCY.prefix,
+			currency_suffix: SITE_CURRENCY.suffix,
+			price: priceInMinorUnits,
+			regular_price: priceInMinorUnits,
+			sale_price: priceInMinorUnits,
+			price_range: null,
+		},
+		price_html: '',
+		average_rating: '0',
+		review_count: 0,
+		images: [],
+		categories: [],
+		tags: [],
+		attributes: [],
+		variations: [],
+		has_options: false,
+		is_purchasable: false,
+		is_in_stock: true,
+		is_on_backorder: false,
+		low_stock_remaining: null,
+		stock_availability: { text: '', class: '' },
+		sold_individually: false,
+		weight: '',
+		dimensions: { length: '', width: '', height: '' },
+		formatted_weight: '',
+		formatted_dimensions: '',
+		price: PLACEHOLDER_PRICE,
+		regular_price: PLACEHOLDER_PRICE,
+		sale_price: '',
+		add_to_cart: {
+			text: __( 'Add to cart', 'woocommerce' ),
+			description: __( 'Add to cart', 'woocommerce' ),
+			url: '',
+			minimum: 1,
+			maximum: 99,
+			multiple_of: 1,
+			single_text: __( 'Add to cart', 'woocommerce' ),
+		},
+		grouped_products: [],
+	};
+};
+
+/**
+ * Creates placeholder product entities and injects them into the
+ * WordPress core data stores so that child blocks (product-image,
+ * product-price, product-button, post-title) render meaningful
+ * previews instead of hardcoded HTML.
+ *
+ * Two entity stores are populated:
+ * - ('postType', 'product') for core post-title block (uses useEntityProp)
+ * - ('root', 'product') for WooCommerce child blocks (uses useProduct hook)
+ */
+export const usePlaceholderProducts = ( {
+	isPreviewWithNoProducts,
+	count,
+}: {
+	isPreviewWithNoProducts: boolean;
+	count: number;
+} ) => {
+	const [ entitiesReady, setEntitiesReady ] = useState( false );
+
+	const placeholderIds = useMemo( () => {
+		if ( ! isPreviewWithNoProducts ) {
+			return [];
+		}
+		const safeCount = Math.max( 1, Math.min( count, 10 ) );
+		return Array.from(
+			{ length: safeCount },
+			( _, i ) => PLACEHOLDER_ID_BASE - i
+		);
+	}, [ isPreviewWithNoProducts, count ] );
+
+	useEffect( () => {
+		if ( ! isPreviewWithNoProducts || placeholderIds.length === 0 ) {
+			setEntitiesReady( false );
+			return;
+		}
+
+		const placeholderName = __( 'Product name', 'woocommerce' );
+		const storeActions = dispatch( coreStore );
+
+		// WP REST API format — used by core post-title block via useEntityProp.
+		const wpEntities = placeholderIds.map( ( id ) => ( {
+			id,
+			type: 'product',
+			status: 'publish',
+			title: {
+				rendered: placeholderName,
+				raw: placeholderName,
+			},
+		} ) );
+
+		const priceInMinorUnits = getPlaceholderPriceMinorUnits();
+
+		// WC REST API format — used by WooCommerce child blocks via useProduct hook.
+		// Includes both REST API fields (price, regular_price) and Store API fields
+		// (prices object) to support both experimental and non-experimental code paths.
+		const wcEntities = placeholderIds.map( ( id ) => ( {
+			id,
+			name: placeholderName,
+			slug: `placeholder-product-${ id }`,
+			type: 'simple' as const,
+			status: 'publish' as const,
+			permalink: '',
+			price: PLACEHOLDER_PRICE,
+			regular_price: PLACEHOLDER_PRICE,
+			sale_price: '',
+			average_rating: '0',
+			rating_count: 0,
+			stock_status: 'instock' as const,
+			images: [],
+			featured: false,
+			catalog_visibility: 'visible' as const,
+			description: '',
+			short_description: '',
+			sku: '',
+			prices: {
+				currency_code: SITE_CURRENCY.code,
+				currency_symbol: SITE_CURRENCY.symbol,
+				currency_minor_unit: SITE_CURRENCY.minorUnit,
+				currency_prefix: SITE_CURRENCY.prefix,
+				currency_suffix: SITE_CURRENCY.suffix,
+				currency_decimal_separator: SITE_CURRENCY.decimalSeparator,
+				currency_thousand_separator: SITE_CURRENCY.thousandSeparator,
+				price: priceInMinorUnits,
+				regular_price: priceInMinorUnits,
+				sale_price: priceInMinorUnits,
+				price_range: null,
+			},
+		} ) );
+
+		// Inject into both entity stores.
+		// Args: kind, name, records, query, invalidateCache, edits, meta.
+		storeActions.receiveEntityRecords(
+			'postType',
+			'product',
+			wpEntities,
+			null,
+			false,
+			null,
+			null
+		);
+		storeActions.receiveEntityRecords(
+			'root',
+			'product',
+			wcEntities,
+			null,
+			false,
+			null,
+			null
+		);
+
+		// Mark resolutions as finished to prevent API fetch attempts
+		// for these placeholder IDs. Both getEntityRecord (the actual
+		// resolver) and getEditedEntityRecord (checked by useProduct
+		// hook's hasFinishedResolution) need to be finished.
+		for ( const id of placeholderIds ) {
+			storeActions.finishResolution( 'getEntityRecord', [
+				'root',
+				'product',
+				id,
+			] );
+			storeActions.finishResolution( 'getEntityRecord', [
+				'postType',
+				'product',
+				id,
+			] );
+			storeActions.finishResolution( 'getEditedEntityRecord', [
+				'root',
+				'product',
+				id,
+			] );
+			storeActions.finishResolution( 'getEditedEntityRecord', [
+				'postType',
+				'product',
+				id,
+			] );
+		}
+
+		setEntitiesReady( true );
+	}, [ isPreviewWithNoProducts, placeholderIds ] );
+
+	const blockContexts = useMemo( () => {
+		if ( ! entitiesReady ) {
+			return null;
+		}
+		return placeholderIds.map( ( id ) => ( {
+			postType: 'product',
+			postId: id,
+		} ) );
+	}, [ entitiesReady, placeholderIds ] );
+
+	// Store API format products for ProductDataContextProvider.
+	// This ensures child blocks (price, image, button) that use
+	// withProductDataContext HOC can access product data without
+	// fetching from the Store API.
+	const placeholderProductMap = useMemo( () => {
+		if ( ! entitiesReady ) {
+			return new Map< number, ProductResponseItem >();
+		}
+		return new Map(
+			placeholderIds.map( ( id ) => [
+				id,
+				createPlaceholderResponseItem( id ),
+			] )
+		);
+	}, [ entitiesReady, placeholderIds ] );
+
+	return { blockContexts, placeholderProductMap, isReady: entitiesReady };
+};
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
index d145e60c7ff..bd239974699 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
@@ -344,6 +344,36 @@ class HandlerRegistry {
 			}
 		);

+		// Best-sellers and new-arrivals: register preview_query only.
+		// These collections handle their main queries via JS/REST, but need
+		// a preview fallback to show recent products in the email editor
+		// when the store has no best-sellers or new arrivals yet.
+		// The build_query callback is a no-op that returns an empty array
+		// so merge_queries() has nothing extra to merge.
+		$noop_build_query = function () {
+			return array();
+		};
+
+		$this->register_collection_handlers(
+			'woocommerce/product-collection/best-sellers',
+			$noop_build_query,
+			null,
+			null,
+			function () {
+				return $this->get_recent_product_ids_query();
+			}
+		);
+
+		$this->register_collection_handlers(
+			'woocommerce/product-collection/new-arrivals',
+			$noop_build_query,
+			null,
+			null,
+			function () {
+				return $this->get_recent_product_ids_query();
+			}
+		);
+
 		$this->register_collection_handlers(
 			'woocommerce/product-collection/cart-contents',
 			function ( $collection_args ) {
@@ -411,6 +441,29 @@ class HandlerRegistry {
 		return $product_references;
 	}

+	/**
+	 * Get a query that returns the most recent published products.
+	 * Used as a fallback for preview mode when the specific collection query
+	 * might return no results (e.g., no best sellers yet in a new store).
+	 *
+	 * @return array Query args to show recent products.
+	 */
+	private function get_recent_product_ids_query() {
+		$recent_product_ids = wc_get_products(
+			array(
+				'status'  => 'publish',
+				'orderby' => 'date',
+				'order'   => 'DESC',
+				'limit'   => 10,
+				'return'  => 'ids',
+			)
+		);
+
+		return array(
+			'post__in' => ! empty( $recent_product_ids ) ? $recent_product_ids : array( -1 ),
+		);
+	}
+
 	/**
 	 * Get cart product IDs from various sources.
 	 * Handles loading cart products from location context or request params.