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 );
+ }
+ }
}