Commit cf6832da29f for woocommerce

commit cf6832da29f5f915513eb104f3f101d1954ebbd6
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Oct 23 13:17:30 2026 +0100

    Stop add-to-cart links adding the product twice on canonical redirect (#70131)

    * Fix add-to-cart links adding twice after a canonical redirect

    add_to_cart_action() runs on wp_loaded, before WordPress's canonical
    redirect on template_redirect, so the redirected URL kept add-to-cart
    and added the product again. Drop it from same-host canonical redirects.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Add changelog entry for add-to-cart canonical redirect fix

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Keep add-to-cart on canonical redirects that downgrade HTTPS to HTTP

    The session cookie can be Secure on HTTPS requests, so it won't reach an
    HTTP destination and the cart would be lost.

    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/52630-add-to-cart-canonical-redirect b/plugins/woocommerce/changelog/52630-add-to-cart-canonical-redirect
new file mode 100644
index 00000000000..31725d280ae
--- /dev/null
+++ b/plugins/woocommerce/changelog/52630-add-to-cart-canonical-redirect
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop add-to-cart links adding the product twice when WordPress redirects them to the canonical URL.
diff --git a/plugins/woocommerce/includes/class-wc-form-handler.php b/plugins/woocommerce/includes/class-wc-form-handler.php
index fc6e5d64a4b..6cf67694c4d 100644
--- a/plugins/woocommerce/includes/class-wc-form-handler.php
+++ b/plugins/woocommerce/includes/class-wc-form-handler.php
@@ -61,6 +61,7 @@ class WC_Form_Handler {
 		add_action( 'wp_loaded', array( __CLASS__, 'redirect_after_cancel_order' ), PHP_INT_MAX );
 		add_action( 'wp_loaded', array( __CLASS__, 'update_cart_action' ), 20 );
 		add_action( 'wp_loaded', array( __CLASS__, 'add_to_cart_action' ), 20 );
+		add_filter( 'redirect_canonical', array( __CLASS__, 'remove_add_to_cart_from_canonical_redirect' ), 10, 2 );

 		// May need $wp global to access query vars.
 		add_action( 'wp', array( __CLASS__, 'pay_action' ), 20 );
@@ -1264,6 +1265,36 @@ class WC_Form_Handler {
 		}
 	}

+	/**
+	 * Remove add-to-cart from WordPress's canonical redirect, since add_to_cart_action() has already added the product.
+	 *
+	 * Redirects to another host (www vs non-www), or from HTTPS to HTTP, keep it, because the cart session cookie may not carry across.
+	 *
+	 * @since 11.4.0
+	 * @internal
+	 *
+	 * @param string|false $redirect_url  The canonical redirect URL.
+	 * @param string       $requested_url The requested URL.
+	 * @return string|false The redirect URL.
+	 */
+	public static function remove_add_to_cart_from_canonical_redirect( $redirect_url, $requested_url = '' ) {
+		if ( ! is_string( $redirect_url ) || ! is_string( $requested_url ) ) {
+			return $redirect_url;
+		}
+
+		if ( ! isset( $_REQUEST['add-to-cart'] ) || ! is_numeric( wp_unslash( $_REQUEST['add-to-cart'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+			return $redirect_url;
+		}
+
+		$is_https_downgrade = 'https' === strtolower( (string) wp_parse_url( $requested_url, PHP_URL_SCHEME ) ) && 'http' === strtolower( (string) wp_parse_url( $redirect_url, PHP_URL_SCHEME ) );
+
+		if ( $is_https_downgrade || wp_parse_url( $redirect_url, PHP_URL_HOST ) !== wp_parse_url( $requested_url, PHP_URL_HOST ) ) {
+			return $redirect_url;
+		}
+
+		return remove_query_arg( 'add-to-cart', $redirect_url );
+	}
+
 	/**
 	 * Handle adding simple products to the cart.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
index 268c25bc0ef..f9049b9c48c 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
@@ -995,6 +995,73 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
 		$this->assertSame( 'Updated Customer', $updated_user->display_name, 'Display name should save when changed to a non-email value.' );
 	}

+	/**
+	 * @testdox An add-to-cart link that WordPress redirects to its canonical URL adds the product once.
+	 *
+	 * @covers WC_Form_Handler::remove_add_to_cart_from_canonical_redirect()
+	 */
+	public function test_add_to_cart_link_with_canonical_redirect_adds_product_once(): void {
+		global $wp_rewrite;
+
+		$original_permalink_structure = (string) $wp_rewrite->permalink_structure;
+		$this->set_permalink_structure( '/%postname%/' );
+
+		try {
+			$product = WC_Helper_Product::create_simple_product();
+			self::factory()->post->create(
+				array(
+					'post_type'   => 'page',
+					'post_name'   => 'add-to-cart-landing',
+					'post_status' => 'publish',
+				)
+			);
+			$requested_url = home_url( '/add-to-cart-landing?add-to-cart=' . $product->get_id() );
+
+			$this->go_to( $requested_url );
+			$_REQUEST = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Mirrors PHP building the request from the query string.
+			WC_Form_Handler::add_to_cart_action();
+
+			$redirect_url = redirect_canonical( $requested_url, false );
+			$this->assertIsString( $redirect_url, 'WordPress should redirect to the trailing-slash URL.' );
+
+			$this->go_to( $redirect_url );
+			$_REQUEST = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Mirrors PHP building the request from the query string.
+			WC_Form_Handler::add_to_cart_action();
+		} finally {
+			$this->set_permalink_structure( $original_permalink_structure );
+		}
+
+		$this->assertSame( 1, WC()->cart->get_cart_contents_count(), 'The redirected request should not add the product again.' );
+		$this->assertSame( home_url( '/add-to-cart-landing/' ), $redirect_url, 'The canonical redirect should drop add-to-cart.' );
+	}
+
+	/**
+	 * @testdox remove_add_to_cart_from_canonical_redirect() only drops add-to-cart from same-host redirects of add-to-cart requests that do not downgrade to HTTP.
+	 *
+	 * @testWith ["12", "http://example.org/shop/?add-to-cart=12&quantity=2", "http://example.org/shop?add-to-cart=12&quantity=2", "http://example.org/shop/?quantity=2"]
+	 *           [null, "http://example.org/shop/?add-to-cart=12", "http://example.org/shop?add-to-cart=12", "http://example.org/shop/?add-to-cart=12"]
+	 *           ["abc", "http://example.org/shop/?add-to-cart=abc", "http://example.org/shop?add-to-cart=abc", "http://example.org/shop/?add-to-cart=abc"]
+	 *           ["12", "http://www.example.org/shop/?add-to-cart=12", "http://example.org/shop?add-to-cart=12", "http://www.example.org/shop/?add-to-cart=12"]
+	 *           ["12", "http://example.org/shop/?add-to-cart=12", "https://example.org/shop?add-to-cart=12", "http://example.org/shop/?add-to-cart=12"]
+	 *           ["12", "https://example.org/shop/?add-to-cart=12", "http://example.org/shop?add-to-cart=12", "https://example.org/shop/"]
+	 *           ["12", false, "http://example.org/shop?add-to-cart=12", false]
+	 *
+	 * @covers WC_Form_Handler::remove_add_to_cart_from_canonical_redirect()
+	 *
+	 * @param string|null  $add_to_cart   The request's add-to-cart value, or null when absent.
+	 * @param string|false $redirect_url  Canonical redirect URL.
+	 * @param string       $requested_url Requested URL.
+	 * @param string|false $expected      Expected redirect URL.
+	 */
+	public function test_remove_add_to_cart_from_canonical_redirect( ?string $add_to_cart, $redirect_url, string $requested_url, $expected ): void {
+		unset( $_REQUEST['add-to-cart'] );
+		if ( null !== $add_to_cart ) {
+			$_REQUEST['add-to-cart'] = $add_to_cart;
+		}
+
+		$this->assertSame( $expected, WC_Form_Handler::remove_add_to_cart_from_canonical_redirect( $redirect_url, $requested_url ) );
+	}
+
 	/**
 	 * Prepares request globals for the account details handler.
 	 *