Commit e84d5b7170b for woocommerce

commit e84d5b7170ba83c34de4091b0457ddb40552dd3b
Author: Mario Santos <34552881+SantosGuillamot@users.noreply.github.com>
Date:   Tue Mar 31 07:55:51 2026 +0200

    Fix: Product title with "$" breaks Add to cart with options (#63653)

    * Use `preg_replace_callback` in quantity steppers

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Update `phpstan`

    * Add unit test

    * Reverse callbacks order

    * Fix phpstan errors

    * Change also return

    * Apply changes to AddToCartForm

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/63653-fix-page-title-with-$-breaks-add-to-cart-with-options b/plugins/woocommerce/changelog/63653-fix-page-title-with-$-breaks-add-to-cart-with-options
new file mode 100644
index 00000000000..7a0b33c601f
--- /dev/null
+++ b/plugins/woocommerce/changelog/63653-fix-page-title-with-$-breaks-add-to-cart-with-options
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix product title with $ breaking Add to cart with options
\ No newline at end of file
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 3566c30f31f..0b4aafe57bd 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -51375,12 +51375,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/AddToCartForm.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartForm\:\:add_steppers\(\) should return string but returns string\|null\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Blocks/BlockTypes/AddToCartForm.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartForm\:\:enqueue_assets\(\) has no return type specified\.$#'
 			identifier: missingType.return
@@ -51405,12 +51399,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/AddToCartForm.php

-		-
-			message: '#^Parameter \#3 \$subject of function preg_replace expects array\<float\|int\|string\>\|string, string\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Blocks/BlockTypes/AddToCartForm.php
-
 		-
 			message: '#^Parameter \$block of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartForm\:\:enqueue_assets\(\) has invalid type Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
 			identifier: class.notFound
@@ -51591,12 +51579,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php

-		-
-			message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartWithOptions\\Utils\:\:add_quantity_steppers\(\) should return string but returns string\|null\.$#'
-			identifier: return.type
-			count: 1
-			path: src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
-
 		-
 			message: '#^Parameter \#1 \$haystack of function strpos expects string, string\|true\|null given\.$#'
 			identifier: argument.type
@@ -51609,12 +51591,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php

-		-
-			message: '#^Parameter \#3 \$subject of function preg_replace expects array\<float\|int\|string\>\|string, string\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
-
 		-
 			message: '#^Parameter \$block of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartWithOptions\\VariationDescription\:\:render\(\) has invalid type Automattic\\WooCommerce\\Blocks\\BlockTypes\\AddToCartWithOptions\\WP_Block\.$#'
 			identifier: class.notFound
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php
index ff5f78321ef..1895b6ce896 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartForm.php
@@ -77,14 +77,26 @@ class AddToCartForm extends AbstractBlock {
 		// Regex pattern to match the <input> element with id starting with 'quantity_'.
 		$pattern = '/(<input[^>]*id="quantity_[^"]*"[^>]*\/>)/';
 		// Replacement string to add button AFTER the matched <input> element.
-		/* translators: %s refers to the item name in the cart. */
-		$minus_button = '$1<button aria-label="' . esc_attr( sprintf( __( 'Reduce quantity of %s', 'woocommerce' ), $product_name ) ) . '" type="button" data-wp-on--click="actions.removeQuantity" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">−</button>';
-		// Replacement string to add button AFTER the matched <input> element.
-		/* translators: %s refers to the item name in the cart. */
-		$plus_button = '$1<button aria-label="' . esc_attr( sprintf( __( 'Increase quantity of %s', 'woocommerce' ), $product_name ) ) . '" type="button" data-wp-on--click="actions.addQuantity" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">+</button>';
-		$new_html    = preg_replace( $pattern, $plus_button, $product_html );
-		$new_html    = preg_replace( $pattern, $minus_button, $new_html );
-		return $new_html;
+		// Use preg_replace_callback to avoid backreference interpretation of $, \ sequences in product names.
+		$new_html = preg_replace_callback(
+			$pattern,
+			function ( $matches ) use ( $product_name ) {
+				/* translators: %s refers to the item name in the cart. */
+				$plus_aria = esc_attr( sprintf( __( 'Increase quantity of %s', 'woocommerce' ), $product_name ) );
+				return $matches[1] . '<button aria-label="' . $plus_aria . '" type="button" data-wp-on--click="actions.addQuantity" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">+</button>';
+			},
+			$product_html ?? ''
+		);
+		$new_html = preg_replace_callback(
+			$pattern,
+			function ( $matches ) use ( $product_name ) {
+				/* translators: %s refers to the item name in the cart. */
+				$minus_aria = esc_attr( sprintf( __( 'Reduce quantity of %s', 'woocommerce' ), $product_name ) );
+				return $matches[1] . '<button aria-label="' . $minus_aria . '" type="button" data-wp-on--click="actions.removeQuantity" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">−</button>';
+			},
+			$new_html ?? ''
+		);
+		return $new_html ?? '';
 	}

 	/**
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
index 2a8f8d90346..117a06a16bf 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/Utils.php
@@ -42,14 +42,26 @@ class Utils {
 		// Regex pattern to match the <input> element with id starting with 'quantity_'.
 		$pattern = '/(<input[^>]*id="quantity_[^"]*"[^>]*\/>)/';
 		// Replacement string to add button AFTER the matched <input> element.
-		/* translators: %s refers to the item name in the cart. */
-		$minus_button = '$1<button aria-label="' . esc_attr( sprintf( __( 'Reduce quantity of %s', 'woocommerce' ), $product_name ) ) . '" type="button" data-wp-on--click="actions.decreaseQuantity" data-wp-bind--disabled="!state.allowsDecrease" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">−</button>';
-		// Replacement string to add button AFTER the matched <input> element.
-		/* translators: %s refers to the item name in the cart. */
-		$plus_button = '$1<button aria-label="' . esc_attr( sprintf( __( 'Increase quantity of %s', 'woocommerce' ), $product_name ) ) . '" type="button" data-wp-on--click="actions.increaseQuantity" data-wp-bind--disabled="!state.allowsIncrease" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">+</button>';
-		$new_html    = preg_replace( $pattern, $plus_button, $quantity_html );
-		$new_html    = preg_replace( $pattern, $minus_button, $new_html );
-		return $new_html;
+		// Use preg_replace_callback to avoid backreference interpretation of $, \ sequences in product names.
+		$new_html = preg_replace_callback(
+			$pattern,
+			function ( $matches ) use ( $product_name ) {
+				/* translators: %s refers to the item name in the cart. */
+				$plus_aria = esc_attr( sprintf( __( 'Increase quantity of %s', 'woocommerce' ), $product_name ) );
+				return $matches[1] . '<button aria-label="' . $plus_aria . '" type="button" data-wp-on--click="actions.increaseQuantity" data-wp-bind--disabled="!state.allowsIncrease" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus">+</button>';
+			},
+			$quantity_html ?? ''
+		);
+		$new_html = preg_replace_callback(
+			$pattern,
+			function ( $matches ) use ( $product_name ) {
+				/* translators: %s refers to the item name in the cart. */
+				$minus_aria = esc_attr( sprintf( __( 'Reduce quantity of %s', 'woocommerce' ), $product_name ) );
+				return $matches[1] . '<button aria-label="' . $minus_aria . '" type="button" data-wp-on--click="actions.decreaseQuantity" data-wp-bind--disabled="!state.allowsDecrease" class="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus">−</button>';
+			},
+			$new_html ?? ''
+		);
+		return $new_html ?? '';
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
index dd82bb347f9..b2040fafe30 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
@@ -444,6 +444,32 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
 		);
 	}

+	/**
+	 * Tests that the stepper buttons render with correct aria labels when the product name contains a dollar sign.
+	 */
+	public function test_stepper_renders_correctly_with_dollar_sign_in_product_name() {
+		$simple_product = new \WC_Product_Simple();
+		$simple_product->set_regular_price( 10 );
+		$simple_product->set_name( 'CANADA, $1' );
+		$simple_product->set_manage_stock( true );
+		$simple_product->set_stock_quantity( 10 );
+		$simple_product_id = $simple_product->save();
+
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $simple_product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
+
+		$this->assertStringContainsString( 'wc-block-components-quantity-selector__button--minus', $markup, 'The minus stepper button is rendered.' );
+		$this->assertStringContainsString( 'wc-block-components-quantity-selector__button--plus', $markup, 'The plus stepper button is rendered.' );
+		$this->assertStringContainsString( 'Reduce quantity of CANADA, $1', $markup, 'The minus button aria-label contains the full product name with dollar sign.' );
+		$this->assertStringContainsString( 'Increase quantity of CANADA, $1', $markup, 'The plus button aria-label contains the full product name with dollar sign.' );
+
+		// Verify $1 was not interpreted as a backreference (which would inject the captured <input> HTML into the aria-label).
+		$this->assertDoesNotMatchRegularExpression(
+			'/aria-label="[^"]*<input[^"]*"/',
+			$markup,
+			'The aria-label should not contain HTML from backreference expansion.'
+		);
+	}
+
 	/**
 	 * Tests that the quantity selector and its steppers are hidden when
 	 * a filter sets min and max quantity to the same value for a product.