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 );
+ }
+}