Commit 39c2b59e7df for woocommerce

commit 39c2b59e7df70a56d431183ffc3123c270dae87b
Author: Andrew Matia <73502748+drewmt@users.noreply.github.com>
Date:   Wed Jul 22 11:06:31 2026 +0300

    Remove nofollow from product permalink buttons (#66743)

    * Fix nofollow on product permalink buttons

    * Add changelog entry for product permalink buttons

    * Fix output buffer cleanup in template tests

    * Simplify product button test mocks

    ---------

    Co-authored-by: Karol Manijak <20098064+kmanijak@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-product-link-nofollow b/plugins/woocommerce/changelog/fix-product-link-nofollow
new file mode 100644
index 00000000000..f7c94dcdfcb
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-link-nofollow
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Remove nofollow from product permalink links in product buttons.
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/block.tsx b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/block.tsx
index 637f53626f7..5e72c1f4203 100644
--- a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/block.tsx
@@ -112,7 +112,7 @@ const AddToCartButtonAdminSide = ( {
 	);
 };

-const AddToCartButton = ( {
+export const AddToCartButton = ( {
 	product,
 	isDescendantOfAddToCartWithOptions,
 	className,
@@ -161,7 +161,6 @@ const AddToCartButton = ( {
 		};
 	} else if ( ! allowAddToCart ) {
 		buttonProps.href = permalink;
-		buttonProps.rel = 'nofollow';
 		buttonProps.onClick = () => {
 			dispatchStoreEvent( 'product-view-link', {
 				product,
diff --git a/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/test/block.test.tsx b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/test/block.test.tsx
new file mode 100644
index 00000000000..b5cc2f01cf8
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/atomic/blocks/product-elements/button/test/block.test.tsx
@@ -0,0 +1,87 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import {
+	useStoreAddToCart,
+	useStoreEvents,
+} from '@woocommerce/base-context/hooks';
+
+/**
+ * Internal dependencies
+ */
+import { AddToCartButton } from '../block';
+import type { AddToCartButtonAttributes } from '../types';
+
+jest.mock( '@woocommerce/base-context/hooks', () => ( {
+	useStoreAddToCart: jest.fn(),
+	useStoreEvents: jest.fn(),
+} ) );
+
+jest.mock( '@woocommerce/base-hooks', () => ( {} ) );
+
+jest.mock( '@woocommerce/block-settings', () => ( {
+	CART_URL: '/cart/',
+} ) );
+
+jest.mock( '@woocommerce/shared-hocs', () => ( {
+	withProductDataContext: ( component ) => component,
+} ) );
+
+const product: AddToCartButtonAttributes[ 'product' ] = {
+	id: 1,
+	type: 'variable',
+	permalink: '/product/example-product/',
+	add_to_cart: {
+		url: '/product/example-product/',
+		description: 'View example product',
+		text: 'Select options',
+		single_text: 'Add to cart',
+	},
+	has_options: true,
+	is_purchasable: true,
+	is_in_stock: true,
+	button_text: 'Select options',
+};
+
+const renderButton = ( overrides: Partial< AddToCartButtonAttributes > = {} ) =>
+	render(
+		<AddToCartButton
+			className=""
+			style={ {} }
+			isDescendantOfAddToCartWithOptions={ false }
+			product={ product }
+			{ ...overrides }
+		/>
+	);
+
+describe( 'AddToCartButton', () => {
+	beforeEach( () => {
+		( useStoreAddToCart as jest.Mock ).mockReturnValue( {
+			cartQuantity: 0,
+			addingToCart: false,
+			addToCart: jest.fn(),
+		} );
+		( useStoreEvents as jest.Mock ).mockReturnValue( {
+			dispatchStoreEvent: jest.fn(),
+		} );
+	} );
+
+	it( 'does not add nofollow to product permalink links', () => {
+		renderButton();
+
+		const link = screen.getByRole( 'link' );
+		expect( link ).toHaveAttribute( 'href', product.permalink );
+		expect( link ).not.toHaveAttribute( 'rel' );
+	} );
+
+	it( 'keeps nofollow on cart action links', () => {
+		renderButton( {
+			collection: 'woocommerce/product-collection/cart-contents',
+		} );
+
+		const link = screen.getByRole( 'link' );
+		expect( link ).toHaveAttribute( 'href', '/cart/' );
+		expect( link ).toHaveAttribute( 'rel', 'nofollow' );
+	} );
+} );
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index b826a01f76c..5d9b60ffe36 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -1457,6 +1457,16 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) {
 			return;
 		}

+		$attributes = array(
+			'data-product_id'  => $product->get_id(),
+			'data-product_sku' => $product->get_sku(),
+			'aria-label'       => $product->add_to_cart_description(),
+		);
+
+		if ( $product->get_permalink() !== $product->add_to_cart_url() ) {
+			$attributes['rel'] = 'nofollow';
+		}
+
 		$defaults = array(
 			'quantity'              => 1,
 			'class'                 => implode(
@@ -1472,12 +1482,7 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) {
 				)
 			),
 			'aria-describedby_text' => $product->add_to_cart_aria_describedby(),
-			'attributes'            => array(
-				'data-product_id'  => $product->get_id(),
-				'data-product_sku' => $product->get_sku(),
-				'aria-label'       => $product->add_to_cart_description(),
-				'rel'              => 'nofollow',
-			),
+			'attributes'            => $attributes,
 		);

 		if ( is_a( $product, 'WC_Product_Simple' ) ) {
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
index 91f2bf93a2e..4f6f0f92839 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
@@ -165,11 +165,15 @@ class ProductButton extends AbstractBlock {
 		);

 		if ( 'a' === $html_element ) {
-			$attributes = array(
-				'href' => esc_url( $product->add_to_cart_url() ),
-				'rel'  => 'nofollow',
+			$add_to_cart_url = $product->add_to_cart_url();
+			$attributes      = array(
+				'href' => esc_url( $add_to_cart_url ),
 			);

+			if ( $product->get_permalink() !== $add_to_cart_url ) {
+				$attributes['rel'] = 'nofollow';
+			}
+
 			if ( $product->is_type( ProductType::EXTERNAL ) ) {
 				$attributes['target'] = '_blank';
 				$attributes['rel']    = 'nofollow noopener noreferrer';
diff --git a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
index a2c0ffec1b9..82f729cb421 100644
--- a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
@@ -7,6 +7,31 @@ declare( strict_types = 1 );
  * @package WooCommerce\Tests\Includes
  */
 class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
+	/**
+	 * Render the loop add-to-cart template for a product.
+	 *
+	 * @param WC_Product $test_product Product to render.
+	 * @return string Rendered template markup.
+	 */
+	private function render_loop_add_to_cart( WC_Product $test_product ): string {
+		global $product;
+
+		$previous_product = $product;
+		$product          = $test_product;
+		$buffer_level     = ob_get_level();
+
+		ob_start();
+		try {
+			woocommerce_template_loop_add_to_cart();
+
+			return (string) ob_get_clean();
+		} finally {
+			while ( ob_get_level() > $buffer_level ) {
+				ob_end_clean();
+			}
+			$product = $previous_product;
+		}
+	}

 	/**
 	 * Helper: create a parent product category with child categories and products.
@@ -145,4 +170,35 @@ class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
 		$this->assertNotFalse( $cached );
 		$this->assertCount( 3, $cached );
 	}
+
+	/**
+	 * @testdox Loop buttons do not add nofollow to product permalink links.
+	 */
+	public function test_loop_button_product_permalink_does_not_include_nofollow(): void {
+		$product = WC_Helper_Product::create_variation_product();
+		$markup  = $this->render_loop_add_to_cart( $product );
+
+		$this->assertStringContainsString( 'href="' . esc_url( $product->get_permalink() ) . '"', $markup );
+		$this->assertStringNotContainsString( 'rel="nofollow"', $markup );
+	}
+
+	/**
+	 * @testdox Loop buttons retain nofollow on direct add-to-cart links.
+	 */
+	public function test_loop_button_direct_add_to_cart_link_retains_nofollow(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$markup  = $this->render_loop_add_to_cart( $product );
+
+		$this->assertStringContainsString( 'rel="nofollow"', $markup );
+	}
+
+	/**
+	 * @testdox Loop buttons retain nofollow on external product links.
+	 */
+	public function test_loop_button_external_product_link_retains_nofollow(): void {
+		$product = WC_Helper_Product::create_external_product();
+		$markup  = $this->render_loop_add_to_cart( $product );
+
+		$this->assertStringContainsString( 'rel="nofollow"', $markup );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
new file mode 100644
index 00000000000..2a385f3d04e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
@@ -0,0 +1,90 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;
+
+use WC_Helper_Product;
+
+/**
+ * Tests for the Product Button block type.
+ */
+class ProductButton extends \WP_UnitTestCase {
+
+	/**
+	 * Previous WooCommerce options to restore after each test.
+	 *
+	 * @var array<string, mixed>
+	 */
+	private $previous_options = array();
+
+	/**
+	 * Set up test options.
+	 */
+	protected function setUp(): void {
+		parent::setUp();
+
+		foreach ( array( 'woocommerce_cart_redirect_after_add', 'woocommerce_enable_ajax_add_to_cart' ) as $option_name ) {
+			$this->previous_options[ $option_name ] = get_option( $option_name, null );
+			update_option( $option_name, 'no' );
+		}
+	}
+
+	/**
+	 * Restore test options.
+	 */
+	protected function tearDown(): void {
+		foreach ( $this->previous_options as $option_name => $value ) {
+			if ( null === $value ) {
+				delete_option( $option_name );
+			} else {
+				update_option( $option_name, $value );
+			}
+		}
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Render the Product Button block for a product.
+	 *
+	 * @param \WC_Product $product Product to render.
+	 * @return string Rendered block markup.
+	 */
+	private function render_product_button( \WC_Product $product ): string {
+		return do_blocks(
+			'<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-button /--><!-- /wp:woocommerce/single-product -->'
+		);
+	}
+
+	/**
+	 * @testdox Product permalink links do not include nofollow.
+	 */
+	public function test_product_permalink_does_not_include_nofollow(): void {
+		$product = WC_Helper_Product::create_variation_product();
+		$markup  = $this->render_product_button( $product );
+
+		$this->assertStringContainsString( 'href="' . esc_url( $product->get_permalink() ) . '"', $markup );
+		$this->assertStringNotContainsString( 'rel="nofollow"', $markup );
+	}
+
+	/**
+	 * @testdox Direct add-to-cart links retain nofollow.
+	 */
+	public function test_direct_add_to_cart_link_retains_nofollow(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$markup  = $this->render_product_button( $product );
+
+		$this->assertStringContainsString( 'rel="nofollow"', $markup );
+	}
+
+	/**
+	 * @testdox External product links retain nofollow and security attributes.
+	 */
+	public function test_external_product_link_retains_nofollow_and_security_attributes(): void {
+		$product = WC_Helper_Product::create_external_product();
+		$markup  = $this->render_product_button( $product );
+
+		$this->assertStringContainsString( 'rel="nofollow noopener noreferrer"', $markup );
+	}
+}