Commit abd1b70085 for woocommerce

commit abd1b7008512ed761c2bd2c1a083533376959145
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Wed Sep 17 16:23:13 2025 +0200

    Add to Cart + Options block: trigger legacy mode only if PHP hooks are used to add form inner elements (#60915)

    * Add changelog file

    * Add to Cart + Options block: trigger legacy mode only if PHP hooks are used to add form inner elements

    * Add tests

    * Typo

diff --git a/plugins/woocommerce/changelog/fix-add-to-cart-with-options-legacy-mode-on-form-elements b/plugins/woocommerce/changelog/fix-add-to-cart-with-options-legacy-mode-on-form-elements
new file mode 100644
index 0000000000..2519b39212
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-add-to-cart-with-options-legacy-mode-on-form-elements
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Add to Cart + Options block: trigger legacy mode only if PHP hooks are used to add form inner elements
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
index 79fc7e944a..acb12f5192 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php
@@ -82,6 +82,23 @@ class AddToCartWithOptions extends AbstractBlock {
 		return $context;
 	}

+	/**
+	 * Check if HTML content has form elements.
+	 *
+	 * @param string $html_content The HTML content.
+	 * @return bool True if the HTML content has form elements, false otherwise.
+	 */
+	public function has_form_elements( $html_content ) {
+		$processor     = new \WP_HTML_Tag_Processor( $html_content );
+		$form_elements = array( 'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'FORM' );
+		while ( $processor->next_tag() ) {
+			if ( in_array( $processor->get_tag(), $form_elements, true ) ) {
+				return true;
+			}
+		}
+		return false;
+	}
+
 	/**
 	 * Check if a child product is purchasable.
 	 *
@@ -502,11 +519,11 @@ class AddToCartWithOptions extends AbstractBlock {

 			$cart_redirect_after_add = get_option( 'woocommerce_cart_redirect_after_add' );
 			$form_attributes         = '';
-			$legacy_mode             = $hooks_before || $hooks_after || 'yes' === $cart_redirect_after_add;
+			$legacy_mode             = 'yes' === $cart_redirect_after_add || $this->has_form_elements( $hooks_before ) || $this->has_form_elements( $hooks_after );
 			if ( $legacy_mode ) {
 				$action_url = home_url( add_query_arg( null, null ) );

-				// If an extension is hoooking into the form or we need to redirect to the cart,
+				// If an extension is hooking into the form or we need to redirect to the cart,
 				// we fall back to a regular HTML form.
 				$form_attributes = array(
 					'action'  => esc_url(
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
index 161ac5fdd0..4eef7194a5 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/AddToCartWithOptions.php
@@ -72,13 +72,25 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
 	}

 	/**
-	 * Hook into the add to cart button action.
+	 * Hook into the add to cart button action with a <select> element.
 	 *
-	 * Outputs a test message when the `woocommerce_before_add_to_cart_button` action is triggered.
-	 * Used for testing that hooks are properly called during add to cart.
+	 * Outputs a select element with an option.
+	 * Used for testing that hooks are properly called during add to cart and
+	 * fall back to a regular HTML form.
 	 */
 	public function hook_into_add_to_cart_button_action() {
-		echo 'Hook into add to cart button action';
+		echo '<select><option>Hook into add to cart button action</option></select>';
+	}
+
+	/**
+	 * Hook into the add to cart button action with a text element.
+	 *
+	 * Outputs a text element.
+	 * Used for testing that text output doesn't trigger a fall back to a
+	 * regular HTML form.
+	 */
+	public function hook_into_add_to_cart_button_action_text() {
+		echo '<p>Hook into add to cart button action</p>';
 	}

 	/**
@@ -247,6 +259,7 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
 		$product->set_regular_price( 10 );
 		$product_id = $product->save();

+		// Test when cart redirect is enabled.
 		update_option( 'woocommerce_cart_redirect_after_add', 'yes' );

 		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
@@ -254,6 +267,7 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
 		$this->assertStringContainsString( 'action="https://example.com"', $markup, 'The form has an action that redirects to the page defined by the woocommerce_add_to_cart_form_action filter.' );
 		$this->assertStringNotContainsString( 'data-wp-on--submit', $markup, 'The form doesn\'t have an on submit event when redirect after add is enabled.' );

+		// Test when cart redirect is disabled.
 		update_option( 'woocommerce_cart_redirect_after_add', 'no' );

 		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
@@ -261,14 +275,26 @@ class AddToCartWithOptions extends \WP_UnitTestCase {
 		$this->assertStringNotContainsString( 'action="https://example.com"', $markup, 'The form doesn\'t have an action that redirects to the page defined by the woocommerce_add_to_cart_form_action filter when redirect after add is disabled.' );
 		$this->assertStringContainsString( 'data-wp-on--submit', $markup, 'The form has an on submit event when redirect after add is disabled.' );

+		// Test when an extension hooks into the form.
 		add_action( 'woocommerce_before_add_to_cart_button', array( $this, 'hook_into_add_to_cart_button_action' ) );

 		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );

-		$this->assertStringContainsString( 'action="https://example.com"', $markup, 'The form has an action that redirects to the page defined by the woocommerce_add_to_cart_form_action filter.' );
+		$this->assertStringContainsString( 'action="https://example.com"', $markup, 'The form has an action that redirects to the page defined by the woocommerce_add_to_cart_form_action filter when an extension hooks into the form.' );
 		$this->assertStringNotContainsString( 'data-wp-on--submit', $markup, 'The form doesn\'t have an on submit event when an extension hooks into the form.' );

 		remove_action( 'woocommerce_before_add_to_cart_button', array( $this, 'hook_into_add_to_cart_button_action' ) );
+
+		// Test when an extension hooks into the form but not adding a form element.
+		add_action( 'woocommerce_before_add_to_cart_button', array( $this, 'hook_into_add_to_cart_button_action_text' ) );
+
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/add-to-cart-with-options /--><!-- /wp:woocommerce/single-product -->' );
+
+		$this->assertStringNotContainsString( 'action="https://example.com"', $markup, 'The form doesn\'t have an action that redirects to the page defined by the woocommerce_add_to_cart_form_action filter when an extension hooks into the form but not adding a form element.' );
+		$this->assertStringContainsString( 'data-wp-on--submit', $markup, 'The form has an on submit event when an extension hooks into the form but not adding a form element.' );
+
+		remove_action( 'woocommerce_before_add_to_cart_button', array( $this, 'hook_into_add_to_cart_button_action_text' ) );
+
 		remove_filter( 'woocommerce_add_to_cart_form_action', array( $this, 'hook_into_woocommerce_add_to_cart_form_action_filter' ) );
 	}