Commit 7bb51d1fad2 for woocommerce
commit 7bb51d1fad28b10cb7e7cf46acf40116fb14208e
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date: Tue Oct 27 08:28:38 2026 +0100
Product Button block: call woocommerce_add_to_cart_quantity when adding products to cart instead of on render (#70139)
* Add changelog
* Update docs
* Product Button block: call woocommerce_add_to_cart_quantity when adding products to cart instead of on render
* Add tests
* Remove unnecessary docblock
* Harden test
* Remove unnecessary tests
* Update woocommerce_loop_add_to_cart_args docblocks
* Add missing since in docblock
* Add back tests util
* PHPStan
* Fix tests
* Remove leftover after merge
diff --git a/plugins/woocommerce/changelog/fix-56454-iapi-product-button-woocommerce_add_to_cart_quantity-on-add b/plugins/woocommerce/changelog/fix-56454-iapi-product-button-woocommerce_add_to_cart_quantity-on-add
new file mode 100644
index 00000000000..d4c6596993f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-56454-iapi-product-button-woocommerce_add_to_cart_quantity-on-add
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Product Button block: call woocommerce_add_to_cart_quantity when adding products to cart instead of on render
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index 6e576978659..66485a369f1 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -372,23 +372,15 @@ apply_filters( 'woocommerce_add_to_cart_form_action', string $action_url )
## woocommerce_add_to_cart_quantity
-Filters the change the quantity to add to cart.
+This filter is documented in includes/class-wc-cart.php.
```php
-apply_filters( 'woocommerce_add_to_cart_quantity', int|float $default_quantity, int $product_id, int $variation_id )
+apply_filters( 'woocommerce_add_to_cart_quantity' )
```
-### Parameters
-
-| Argument | Type | Description |
-| -------- | ---- | ----------- |
-| $default_quantity | int, float | The default quantity. |
-| $product_id | int | The product id. |
-| $variation_id | int | The variation ID. Always 0 in this context. |
-
### Source
-- [Blocks/BlockTypes/ProductButton.php](../../../../../../src/Blocks/BlockTypes/ProductButton.php)
+- [StoreApi/Utilities/CartController.php](../../../../../../src/StoreApi/Utilities/CartController.php)
---
@@ -1219,19 +1211,12 @@ Allows backward compatibility with the `rest_request_after_callbacks` filter by
## woocommerce_loop_add_to_cart_args
-Allow filtering of the add to cart button arguments.
+This filter is documented in includes/wc-template-functions.php.
```php
-apply_filters( 'woocommerce_loop_add_to_cart_args', array $args, \WC_Product $product )
+apply_filters( 'woocommerce_loop_add_to_cart_args' )
```
-### Parameters
-
-| Argument | Type | Description |
-| -------- | ---- | ----------- |
-| $args | array | Button arguments, with a `class` string and an `attributes` array. |
-| $product | \WC_Product | Product the button is rendered for. |
-
### Source
- [Blocks/BlockTypes/ProductButton.php](../../../../../../src/Blocks/BlockTypes/ProductButton.php)
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index 26ba5c94168..d8c7f934003 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -1491,10 +1491,10 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) {
}
/**
- * Filter to customize the arguments for the add to cart template for the loop.
- *
- * @param array $args Arguments.
+ * Filter to customize the arguments for the add to cart button in the loop.
*
+ * @param array $args Arguments.
+ * @param \WC_Product $product Product object.
* @since 2.4.11
*/
$args = apply_filters( 'woocommerce_loop_add_to_cart_args', wp_parse_args( $args, $defaults ), $product );
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index c6cc362c421..5cf3e639980 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -34770,7 +34770,7 @@ parameters:
-
message: '#^Expected 2 @param tags, found 1\.$#'
identifier: paramTag.count
- count: 2
+ count: 1
path: includes/wc-template-functions.php
-
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
index d0159273158..580f70a04b0 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
@@ -118,41 +118,12 @@ class ProductButton extends AbstractBlock {
)
);
- $default_quantity = 1;
-
- if ( ! $is_descendant_of_add_to_cart_form ) {
- /**
- * Filters the change the quantity to add to cart.
- *
- * @since 8.5.0
- * @since 11.0.0 Added the `$variation_id` parameter.
- * @param int|float $default_quantity The default quantity.
- * @param int $product_id The product id.
- * @param int $variation_id The variation ID. Always 0 in this context.
- */
- $default_quantity = apply_filters( 'woocommerce_add_to_cart_quantity', $default_quantity, $product->get_id(), 0 );
- }
-
$add_to_cart_text = null !== $product->add_to_cart_text() ? $product->add_to_cart_text() : __( 'Add to cart', 'woocommerce' );
if ( $is_descendant_of_add_to_cart_form && null !== $product->single_add_to_cart_text() ) {
$add_to_cart_text = $product->single_add_to_cart_text();
}
- $context = array(
- 'quantityToAdd' => $default_quantity,
- 'addToCartText' => $add_to_cart_text,
- 'tempQuantity' => $number_of_items_in_cart,
- 'animationStatus' => 'IDLE',
- 'inTheCartText' => $this->get_in_the_cart_text( $product ),
- 'noticeId' => '',
- 'hasPressedButton' => false,
- );
-
- if ( $product->is_type( ProductType::GROUPED ) ) {
- $context['groupedProductIds'] = $product->get_children();
- }
-
$attributes = array(
'type' => $is_descendant_of_add_to_cart_form ? 'submit' : 'button',
);
@@ -174,6 +145,48 @@ class ProductButton extends AbstractBlock {
}
}
+ /**
+ * This filter is documented in includes/wc-template-functions.php.
+ *
+ * @since 2.4.11
+ */
+ $args = apply_filters(
+ 'woocommerce_loop_add_to_cart_args',
+ array(
+ 'quantity' => 1,
+ 'class' => $html_classes,
+ 'attributes' => array_merge(
+ $attributes,
+ array(
+ 'data-product_id' => $product->get_id(),
+ 'data-product_sku' => $product->get_sku(),
+ 'aria-label' => ! $is_descendant_of_add_to_cart_form || 'simple' === $product->get_type() ? $product->add_to_cart_description() : null,
+ ),
+ ),
+ ),
+ $product
+ );
+
+ // Single-product forms take their quantity from the quantity input.
+ $default_quantity = 1;
+ if ( ! $is_descendant_of_add_to_cart_form && isset( $args['quantity'] ) && is_numeric( $args['quantity'] ) ) {
+ $default_quantity = wc_stock_amount( (float) $args['quantity'] );
+ }
+
+ $context = array(
+ 'quantityToAdd' => $default_quantity,
+ 'addToCartText' => $add_to_cart_text,
+ 'tempQuantity' => $number_of_items_in_cart,
+ 'animationStatus' => 'IDLE',
+ 'inTheCartText' => $this->get_in_the_cart_text( $product ),
+ 'noticeId' => '',
+ 'hasPressedButton' => false,
+ );
+
+ if ( $product->is_type( ProductType::GROUPED ) ) {
+ $context['groupedProductIds'] = $product->get_children();
+ }
+
wp_interactivity_config(
'woocommerce',
array(
@@ -216,30 +229,6 @@ class ProductButton extends AbstractBlock {
)
);
- /**
- * Allow filtering of the add to cart button arguments.
- *
- * @since 9.7.0
- *
- * @param array $args Button arguments, with a `class` string and an `attributes` array.
- * @param \WC_Product $product Product the button is rendered for.
- */
- $args = apply_filters(
- 'woocommerce_loop_add_to_cart_args',
- array(
- 'class' => $html_classes,
- 'attributes' => array_merge(
- $attributes,
- array(
- 'data-product_id' => $product->get_id(),
- 'data-product_sku' => $product->get_sku(),
- 'aria-label' => ! $is_descendant_of_add_to_cart_form || 'simple' === $product->get_type() ? $product->add_to_cart_description() : null,
- ),
- ),
- ),
- $product
- );
-
// The accessible name must keep containing the visible text once it changes to "N in cart" (WCAG 2.5.3).
if ( isset( $args['attributes']['aria-label'] ) ) {
$args['attributes']['aria-label'] = wp_strip_all_tags( $args['attributes']['aria-label'] );
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
index cf449024d4e..e70c77a0cf9 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
@@ -108,15 +108,8 @@ class CartController {
]
);
- $request = $this->filter_request_data( $this->parse_variation_data( $request ) );
- $product = $this->get_product_for_cart( $request );
- $cart_id = $cart->generate_cart_id(
- $this->get_product_id( $product ),
- $this->get_variation_id( $product ),
- $request['variation'],
- $request['cart_item_data']
- );
-
+ $request = $this->parse_variation_data( $request );
+ $product = $this->get_product_for_cart( $request );
$quantity_limits = new QuantityLimits();
// If quantity was not passed, it should default to the minimum allowed quantity.
@@ -124,6 +117,29 @@ class CartController {
$request['quantity'] = $quantity_limits->get_add_to_cart_limits( $product )['minimum'];
}
+ $product_id = $this->get_product_id( $product );
+ $variation_id = $this->get_variation_id( $product );
+
+ /**
+ * This filter is documented in includes/class-wc-cart.php.
+ *
+ * @since 3.1.0
+ */
+ $request['quantity'] = apply_filters(
+ 'woocommerce_add_to_cart_quantity',
+ $request['quantity'],
+ $product_id,
+ $variation_id
+ );
+
+ $request = $this->filter_request_data( $request );
+ $cart_id = $cart->generate_cart_id(
+ $product_id,
+ $variation_id,
+ $request['variation'],
+ $request['cart_item_data']
+ );
+
$this->validate_add_to_cart( $product, $request );
$existing_cart_id = $cart->find_product_in_cart( $cart_id );
$request_quantity = wc_stock_amount( $request['quantity'] );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
index 67255b8b034..9b12dab0a9a 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
@@ -62,7 +62,7 @@ class ProductButton extends \WP_UnitTestCase {
*/
private function render_product_button( \WC_Product $product ): string {
return do_blocks(
- '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product"><!-- wp:woocommerce/product-button /--></div><!-- /wp:woocommerce/single-product -->'
+ '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="product-button-test-wrapper"><!-- wp:woocommerce/product-button /--></div><!-- /wp:woocommerce/single-product -->'
);
}
@@ -210,4 +210,53 @@ class ProductButton extends \WP_UnitTestCase {
return $processor->get_attribute( 'aria-label' );
}
+
+ /**
+ * @testdox Product Button starts with a quantity of 1.
+ */
+ public function test_product_button_defaults_quantity_to_one(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $this->assertSame( 1, $this->get_quantity_to_add( $this->render_product_button( $product ) ) );
+ }
+
+ /**
+ * @testdox woocommerce_loop_add_to_cart_args sets the Product Button quantity.
+ */
+ public function test_loop_add_to_cart_args_sets_initial_quantity(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $filter = static function ( array $args ): array {
+ $args['quantity'] = 4;
+ return $args;
+ };
+
+ add_filter( 'woocommerce_loop_add_to_cart_args', $filter );
+ try {
+ $markup = $this->render_product_button( $product );
+ } finally {
+ remove_filter( 'woocommerce_loop_add_to_cart_args', $filter );
+ }
+
+ $this->assertSame( 4, $this->get_quantity_to_add( $markup ) );
+ }
+
+ /**
+ * Read quantityToAdd from the rendered Product Button context.
+ *
+ * @param string $markup Rendered block markup.
+ * @return int
+ */
+ private function get_quantity_to_add( string $markup ) {
+ $processor = new \WP_HTML_Tag_Processor( $markup );
+ $this->assertTrue(
+ $processor->next_tag( array( 'class_name' => 'wc-block-components-product-button' ) ),
+ 'Product Button wrapper was not rendered.'
+ );
+
+ $context = json_decode( (string) $processor->get_attribute( 'data-wp-context' ), true );
+ $this->assertIsArray( $context );
+ $this->assertArrayHasKey( 'quantityToAdd', $context );
+
+ return $context['quantityToAdd'];
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php
index ba8a04cef5e..475c4302f30 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php
@@ -285,4 +285,87 @@ class CartControllerTests extends \WC_Unit_Test_Case {
remove_all_filters( 'woocommerce_cart_shipping_packages' );
}
+
+ /**
+ * @testdox Add to cart applies woocommerce_add_to_cart_quantity to the requested quantity.
+ */
+ public function test_add_to_cart_applies_quantity_filter(): void {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Quantity Filter Product',
+ 'regular_price' => 10,
+ )
+ );
+
+ $captured = array();
+ $filter = static function ( $quantity, $product_id, $variation_id ) use ( &$captured ) {
+ $captured = array( $quantity, $product_id, $variation_id );
+ return 4;
+ };
+
+ add_filter( 'woocommerce_add_to_cart_quantity', $filter, 10, 3 );
+ try {
+ $cart_id = ( new CartController() )->add_to_cart(
+ array(
+ 'id' => $product->get_id(),
+ 'quantity' => 1,
+ )
+ );
+ } finally {
+ remove_filter( 'woocommerce_add_to_cart_quantity', $filter, 10 );
+ }
+
+ $this->assertSame( array( 1, $product->get_id(), 0 ), $captured );
+ $this->assertSame( 4, wc()->cart->get_cart_item( $cart_id )['quantity'] );
+ }
+
+ /**
+ * @testdox Add to cart passes the variation ID to woocommerce_add_to_cart_quantity.
+ */
+ public function test_add_to_cart_quantity_filter_receives_variation_id(): void {
+ $product = \WC_Helper_Product::create_variation_product();
+ $variation = null;
+ foreach ( $product->get_available_variations() as $available_variation ) {
+ if ( ! in_array( '', $available_variation['attributes'], true ) ) {
+ $variation = $available_variation;
+ break;
+ }
+ }
+ $this->assertIsArray( $variation, 'Expected a variation with every attribute set.' );
+
+ $variation_request = array();
+ foreach ( $variation['attributes'] as $name => $value ) {
+ $attribute = $name;
+ if ( str_starts_with( $attribute, 'attribute_' ) ) {
+ $attribute = substr( $attribute, strlen( 'attribute_' ) );
+ }
+ $variation_request[] = array(
+ 'attribute' => $attribute,
+ 'value' => $value,
+ );
+ }
+
+ $captured = array();
+ $filter = static function ( $quantity, $product_id, $variation_id ) use ( &$captured ) {
+ $captured = array( $quantity, $product_id, $variation_id );
+ return 4;
+ };
+
+ add_filter( 'woocommerce_add_to_cart_quantity', $filter, 10, 3 );
+ try {
+ $cart_id = ( new CartController() )->add_to_cart(
+ array(
+ 'id' => $variation['variation_id'],
+ 'quantity' => 1,
+ 'variation' => $variation_request,
+ )
+ );
+ } finally {
+ remove_filter( 'woocommerce_add_to_cart_quantity', $filter, 10 );
+ }
+
+ $this->assertSame( array( 1, $product->get_id(), (int) $variation['variation_id'] ), $captured );
+ $this->assertSame( 4, wc()->cart->get_cart_item( $cart_id )['quantity'] );
+ }
}