Commit fac2ba48d0b for woocommerce

commit fac2ba48d0b150365ffdb5656415ae1ad16681ee
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Wed Jun 17 17:20:16 2026 +0200

    Shopper lists: Add Wishlist Button back to Add to Cart With Options  (#65765)

    * Add opt-in Add to Wishlist toggle to Add to Cart + Options block

    * Add changelog entry for Add to Wishlist toggle

    * Gate Add to Wishlist Button on the feature flag, remove block toggle

    * Update changelog for Add to Wishlist feature-flag gating

    * Guard strpos() position comparison in wishlist injection test

diff --git a/plugins/woocommerce/changelog/add-wishlist-toggle-to-add-to-cart-with-options b/plugins/woocommerce/changelog/add-wishlist-toggle-to-add-to-cart-with-options
new file mode 100644
index 00000000000..7e0c1730154
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-wishlist-toggle-to-add-to-cart-with-options
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Render the Add to Wishlist Button inside the Add to Cart + Options block as the last child when the (experimental) wishlist feature is enabled, without modifying the shipped template parts.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/edit/edit-template-part.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/edit/edit-template-part.tsx
index d427ea69ee4..039d2223d53 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/edit/edit-template-part.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/edit/edit-template-part.tsx
@@ -1,6 +1,7 @@
 /**
  * External dependencies
  */
+import { __ } from '@wordpress/i18n';
 import { useSelect } from '@wordpress/data';
 import { useEntityBlockEditor, store as coreStore } from '@wordpress/core-data';
 import {
@@ -8,6 +9,7 @@ import {
 	useInnerBlocksProps,
 	useBlockProps,
 } from '@wordpress/block-editor';
+import { Icon, starEmpty } from '@wordpress/icons';
 import { getSetting } from '@woocommerce/settings';

 /**
@@ -15,14 +17,40 @@ import { getSetting } from '@woocommerce/settings';
  */
 import { Skeleton } from './skeleton';

+/**
+ * Non-editable preview of the Add to Wishlist Button block, shown as the last
+ * child of the template part when the merchant enables the toggle. The button
+ * isn't a real inner block here (the template part is loaded separately and we
+ * don't want to persist it into the shared template part), so this just mirrors
+ * the block's editor markup to show where it'll appear on the frontend.
+ */
+const AddToWishlistPreview = () => (
+	<div className="wc-block-add-to-wishlist-button">
+		<button
+			type="button"
+			className="wc-block-add-to-wishlist-button__toggle"
+			disabled
+		>
+			<span className="wc-block-add-to-wishlist-button__icon wc-block-add-to-wishlist-button__icon--empty">
+				<Icon icon={ starEmpty } size={ 24 } />
+			</span>
+			<span className="wc-block-add-to-wishlist-button__label">
+				{ __( 'Add to wishlist', 'woocommerce' ) }
+			</span>
+		</button>
+	</div>
+);
+
 const TemplatePartInnerBlocks = ( {
 	blockProps,
 	productType,
 	templatePartId,
+	showAddToWishlist,
 }: {
 	blockProps: Record< string, unknown >;
 	productType: string;
 	templatePartId: string | undefined;
+	showAddToWishlist: boolean;
 } ) => {
 	const [ blocks, onInput, onChange ] = useEntityBlockEditor(
 		'postType',
@@ -64,13 +92,22 @@ const TemplatePartInnerBlocks = ( {
 		);
 	}

-	return <div { ...innerBlocksProps } />;
+	const { children, ...innerBlocksWrapperProps } = innerBlocksProps;
+
+	return (
+		<div { ...innerBlocksWrapperProps }>
+			{ children }
+			{ showAddToWishlist && <AddToWishlistPreview /> }
+		</div>
+	);
 };

 export const AddToCartWithOptionsEditTemplatePart = ( {
 	productType,
+	showAddToWishlist,
 }: {
 	productType: string;
+	showAddToWishlist: boolean;
 } ) => {
 	const addToCartWithOptionsTemplatePartIds = getSetting(
 		'addToCartWithOptionsTemplatePartIds',
@@ -121,6 +158,7 @@ export const AddToCartWithOptionsEditTemplatePart = ( {
 		return (
 			<div { ...blockProps }>
 				<Skeleton productType={ productType } isLoading={ isLoading } />
+				{ showAddToWishlist && <AddToWishlistPreview /> }
 			</div>
 		);
 	}
@@ -130,6 +168,7 @@ export const AddToCartWithOptionsEditTemplatePart = ( {
 			blockProps={ blockProps }
 			productType={ productType }
 			templatePartId={ templatePartId }
+			showAddToWishlist={ showAddToWishlist }
 		/>
 	);
 };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/edit/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/edit/index.tsx
index 36956ef452b..e5d68602a12 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/edit/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/edit/index.tsx
@@ -8,6 +8,7 @@ import {
 	InspectorControls,
 	useBlockProps,
 } from '@wordpress/block-editor';
+import { getSetting } from '@woocommerce/settings';
 import { useProduct } from '@woocommerce/entities';

 /**
@@ -24,6 +25,10 @@ import type { Attributes } from '../types';
 const AddToCartOptionsEdit = (
 	props: BlockEditProps< Attributes > & { context?: { postId?: number } }
 ) => {
+	const isWishlistFeatureEnabled = getSetting< boolean >(
+		'wishlistFeatureEnabled',
+		false
+	);
 	const { product } = useProduct( props.context?.postId );
 	const blockProps = useBlockProps( {
 		className: 'wc-block-add-to-cart-with-options',
@@ -61,6 +66,7 @@ const AddToCartOptionsEdit = (
 			{ isCoreProductType ? (
 				<AddToCartWithOptionsEditTemplatePart
 					productType={ productType }
+					showAddToWishlist={ isWishlistFeatureEnabled }
 				/>
 			) : (
 				<div { ...blockProps }>
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
index f2033195f5d..383cd907f1a 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\Blocks\Utils\StyleAttributesUtils;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
+use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;

 /**
  * AddToCartWithOptions class.
@@ -103,9 +104,23 @@ class AddToCartWithOptions extends AbstractBlock {
 		if ( is_admin() ) {
 			$this->asset_data_registry->add( 'productTypes', wc_get_product_types() );
 			$this->asset_data_registry->add( 'addToCartWithOptionsTemplatePartIds', $this->get_template_part_ids() );
+			$this->asset_data_registry->add( 'wishlistFeatureEnabled', $this->is_wishlist_enabled() );
 		}
 	}

+	/**
+	 * Whether the wishlist feature is enabled.
+	 *
+	 * Gates the render-time injection of the Add to Wishlist Button block (and
+	 * its editor preview), so the button only appears while the (experimental,
+	 * feature-flagged) wishlist feature is on.
+	 *
+	 * @return bool True if the wishlist feature flag is enabled, false otherwise.
+	 */
+	private function is_wishlist_enabled(): bool {
+		return wc_get_container()->get( ShopperListsController::class )->is_enabled( 'wishlist' );
+	}
+
 	/**
 	 * Get template part IDs for each product type.
 	 *
@@ -511,6 +526,15 @@ class AddToCartWithOptions extends AbstractBlock {
 				$hooks_after = ob_get_clean();
 			}

+			// Add to Wishlist Button: when the wishlist feature is enabled,
+			// inject the button as the last child of the template part so it
+			// renders inside the form's iAPI scope (where it can read the
+			// selected variation/attributes). The markup is injected at render
+			// time only, never persisted to the shipped template parts.
+			if ( $this->is_wishlist_enabled() ) {
+				$template_part_contents .= "\n<!-- wp:woocommerce/add-to-wishlist-button /-->";
+			}
+
 			// Because we are printing the template part using do_blocks, context from the outside is lost.
 			// This filter is used to add the isDescendantOfAddToCartWithOptions context back.
 			add_filter( 'render_block_context', array( $this, 'set_is_descendant_of_add_to_cart_with_options_context' ), 10, 2 );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
index a1997039801..1199a2ea9a2 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
@@ -16,6 +16,7 @@ use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelec
 use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorAttributeMock;
 use Automattic\WooCommerce\Tests\Blocks\Mocks\AddToCartWithOptionsVariationSelectorAttributeNameMock;
 use Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\Utils;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;

 /**
  * Tests for the AddToCartWithOptions block type
@@ -517,4 +518,63 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
 		$this->assertStringContainsString( 'wc-block-components-quantity-selector__input', $result, 'The input should receive the stepper input class.' );
 		$this->assertStringContainsString( 'custom_name', $result, 'The original input name value should be preserved.' );
 	}
+
+	/**
+	 * Tests that the Add to Wishlist Button is injected as the last child only
+	 * when the `product_wishlist` feature flag is enabled.
+	 *
+	 * A lightweight stub stands in for the real `add-to-wishlist-button` block so
+	 * the test isolates the ATCWO injection/gating logic (the button's own
+	 * rendering is covered by AddToWishlistButtonTests).
+	 *
+	 * @covers \Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\AddToCartWithOptions::render
+	 */
+	public function test_add_to_wishlist_button_injection() {
+		$marker   = 'wc-block-add-to-wishlist-button-stub';
+		$registry = \WP_Block_Type_Registry::get_instance();
+		$features = wc_get_container()->get( FeaturesController::class );
+		$original = $features->feature_is_enabled( 'product_wishlist' );
+
+		if ( $registry->is_registered( 'woocommerce/add-to-wishlist-button' ) ) {
+			$registry->unregister( 'woocommerce/add-to-wishlist-button' );
+		}
+		register_block_type(
+			'woocommerce/add-to-wishlist-button',
+			array(
+				'render_callback' => function () use ( $marker ) {
+					return '<div class="' . $marker . '"></div>';
+				},
+			)
+		);
+
+		try {
+			global $product;
+			$product = new \WC_Product_Simple();
+			$product->set_regular_price( 10 );
+			$product_id = $product->save();
+			$block      = '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->';
+
+			// Feature on: the button is injected as the last child.
+			$features->change_feature_enable( 'product_wishlist', true );
+			$markup = do_blocks( $block );
+
+			$this->assertStringContainsString( $marker, $markup, 'The Add to Wishlist Button is injected when the wishlist feature is enabled.' );
+			// Confirm the product button is present first, so both strpos() calls
+			// below return integers and the position comparison is meaningful.
+			$this->assertStringContainsString( 'wp-block-woocommerce-product-button', $markup, 'The product button is rendered.' );
+			$this->assertGreaterThan(
+				strpos( $markup, 'wp-block-woocommerce-product-button' ),
+				strpos( $markup, $marker ),
+				'The Add to Wishlist Button is injected after the product button (as the last child).'
+			);
+
+			// Feature off: the button is not injected.
+			$features->change_feature_enable( 'product_wishlist', false );
+			$markup = do_blocks( $block );
+			$this->assertStringNotContainsString( $marker, $markup, 'The Add to Wishlist Button is not injected when the wishlist feature is disabled.' );
+		} finally {
+			$registry->unregister( 'woocommerce/add-to-wishlist-button' );
+			$features->change_feature_enable( 'product_wishlist', $original );
+		}
+	}
 }