Commit ee6866e42e for woocommerce

commit ee6866e42eac7adb29ef941ee1490269ebfc2ee1
Author: Michael Pretty <prettyboymp@users.noreply.github.com>
Date:   Mon Feb 9 09:01:20 2026 -0500

    Skip category processing in product permalinks when not needed (#63121)

    * Skip category processing in product permalinks when not needed

    When the product permalink structure doesn't contain %category% or
    %product_cat% placeholders, skip the expensive category term processing
    that fetches terms and calculates the deepest category.

    This avoids unnecessary database queries (get_the_terms, get_ancestors)
    and filter executions for sites using permalink structures like
    /product/%product%/ or /product/%post_id%/.

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

    * Add missing docblock for woocommerce_product_post_type_link_parent_category_only filter

    Fixes PHPCS CommentHooks.MissingHookComment error.

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

    ---------

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

diff --git a/plugins/woocommerce/changelog/performance-WOOPLUG-6202-skip-category-processing b/plugins/woocommerce/changelog/performance-WOOPLUG-6202-skip-category-processing
new file mode 100644
index 0000000000..4e21d5bcf4
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-WOOPLUG-6202-skip-category-processing
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Skip category term processing in product permalinks when structure doesn't include category placeholders
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index a70f2d3b47..3677172030 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -268,64 +268,81 @@ function wc_product_post_type_link( $permalink, $post ) {
 		return $permalink;
 	}

-	// Get the custom taxonomy terms in use by this post.
-	$terms = get_the_terms( $post->ID, 'product_cat' );
+	// Only process category if the permalink structure uses category placeholders.
+	$needs_category = strpos( $permalink, '%category%' ) !== false || strpos( $permalink, '%product_cat%' ) !== false;
+	$product_cat    = '';

-	if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
-		// Find the deepest category (most ancestors) for the permalink.
-		$deepest_term      = $terms[0];
-		$deepest_ancestors = $deepest_term->parent ? get_ancestors( $deepest_term->term_id, 'product_cat' ) : array();
+	if ( $needs_category ) {
+		// Get the custom taxonomy terms in use by this post.
+		$terms = get_the_terms( $post->ID, 'product_cat' );

-		foreach ( $terms as $term ) {
-			if ( $term->term_id === $deepest_term->term_id ) {
-				continue;
-			}
-			// Skip root categories - they can't be deeper than current.
-			if ( ! $term->parent ) {
-				continue;
-			}
-			$ancestors = get_ancestors( $term->term_id, 'product_cat' );
-			if ( count( $ancestors ) > count( $deepest_ancestors ) ) {
-				$deepest_ancestors = $ancestors;
-				$deepest_term      = $term;
+		if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
+			// Find the deepest category (most ancestors) for the permalink.
+			$deepest_term      = $terms[0];
+			$deepest_ancestors = $deepest_term->parent ? get_ancestors( $deepest_term->term_id, 'product_cat' ) : array();
+
+			foreach ( $terms as $term ) {
+				if ( $term->term_id === $deepest_term->term_id ) {
+					continue;
+				}
+				// Skip root categories - they can't be deeper than current.
+				if ( ! $term->parent ) {
+					continue;
+				}
+				$ancestors = get_ancestors( $term->term_id, 'product_cat' );
+				if ( count( $ancestors ) > count( $deepest_ancestors ) ) {
+					$deepest_ancestors = $ancestors;
+					$deepest_term      = $term;
+				}
 			}
-		}

-		/**
-		 * Filter the product category used for the product permalink.
-		 *
-		 * By default, the deepest category (most ancestors) is selected. Prior to 9.9.0,
-		 * categories were sorted by parent term ID descending, then term ID ascending.
-		 * This filter allows customization of which category is used in the product permalink.
-		 *
-		 * @since 2.4.0
-		 * @since 9.9.0 Selection algorithm changed to use deepest category instead of sort order.
-		 *
-		 * @param WP_Term   $deepest_term The selected category term object (deepest category since 9.9.0).
-		 * @param WP_Term[] $terms        All category terms assigned to the product.
-		 * @param WP_Post   $post         The product post object.
-		 */
-		$category_object = apply_filters( 'wc_product_post_type_link_product_cat', $deepest_term, $terms, $post );
-		$category_object = ! $category_object instanceof WP_Term ? $deepest_term : $category_object;
-		$product_cat     = $category_object->slug;
-
-		if ( $category_object->parent ) {
-			// Reuse cached ancestors if the filter didn't change the category, otherwise fetch them.
-			$ancestors = ( $category_object->term_id === $deepest_term->term_id )
-				? $deepest_ancestors
-				: get_ancestors( $category_object->term_id, 'product_cat' );
-			foreach ( $ancestors as $ancestor ) {
-				$ancestor_object = get_term( $ancestor, 'product_cat' );
-				if ( apply_filters( 'woocommerce_product_post_type_link_parent_category_only', false ) ) {
-					$product_cat = $ancestor_object->slug;
-				} else {
-					$product_cat = $ancestor_object->slug . '/' . $product_cat;
+			/**
+			 * Filter the product category used for the product permalink.
+			 *
+			 * By default, the deepest category (most ancestors) is selected. Prior to 9.9.0,
+			 * categories were sorted by parent term ID descending, then term ID ascending.
+			 * This filter allows customization of which category is used in the product permalink.
+			 *
+			 * @since 2.4.0
+			 * @since 9.9.0 Selection algorithm changed to use deepest category instead of sort order.
+			 *
+			 * @param WP_Term   $deepest_term The selected category term object (deepest category since 9.9.0).
+			 * @param WP_Term[] $terms        All category terms assigned to the product.
+			 * @param WP_Post   $post         The product post object.
+			 */
+			$category_object = apply_filters( 'wc_product_post_type_link_product_cat', $deepest_term, $terms, $post );
+			$category_object = ! $category_object instanceof WP_Term ? $deepest_term : $category_object;
+			$product_cat     = $category_object->slug;
+
+			if ( $category_object->parent ) {
+				// Reuse cached ancestors if the filter didn't change the category, otherwise fetch them.
+				$ancestors = ( $category_object->term_id === $deepest_term->term_id )
+					? $deepest_ancestors
+					: get_ancestors( $category_object->term_id, 'product_cat' );
+				foreach ( $ancestors as $ancestor ) {
+					$ancestor_object = get_term( $ancestor, 'product_cat' );
+
+					/**
+					 * Filter whether to use only the top-level parent category in the product permalink.
+					 *
+					 * When true, only the top-level ancestor category slug is used instead of
+					 * the full category hierarchy path (e.g., 'parent' instead of 'parent/child/grandchild').
+					 *
+					 * @since 2.6.5
+					 *
+					 * @param bool $use_parent_only Whether to use only the top-level parent category. Default false.
+					 */
+					if ( apply_filters( 'woocommerce_product_post_type_link_parent_category_only', false ) ) {
+						$product_cat = $ancestor_object->slug;
+					} else {
+						$product_cat = $ancestor_object->slug . '/' . $product_cat;
+					}
 				}
 			}
+		} else {
+			// If no terms are assigned to this post, use a string instead (can't leave the placeholder there).
+			$product_cat = _x( 'uncategorized', 'slug', 'woocommerce' );
 		}
-	} else {
-		// If no terms are assigned to this post, use a string instead (can't leave the placeholder there).
-		$product_cat = _x( 'uncategorized', 'slug', 'woocommerce' );
 	}

 	$find = array(
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index b93ac13ab8..50b2632103 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -556,4 +556,39 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 		wp_delete_term( $root2_term['term_id'], 'product_cat' );
 		wp_delete_term( $root1_term['term_id'], 'product_cat' );
 	}
+
+	/**
+	 * @testdox Product permalink skips category processing when permalink structure has no category placeholders.
+	 */
+	public function test_wc_product_post_type_link_skips_category_when_not_in_permalink() {
+		// Create a category to ensure it's not fetched unnecessarily.
+		$category_term = wp_insert_term( 'Test Category', 'product_cat' );
+
+		$product = WC_Helper_Product::create_simple_product();
+		wp_set_object_terms( $product->get_id(), array( $category_term['term_id'] ), 'product_cat' );
+
+		$product_post = get_post( $product->get_id() );
+
+		// Test with %post_id% placeholder but no category placeholder.
+		$permalink = wc_product_post_type_link( '/product/%post_id%/' . $product_post->post_name . '/', $product_post );
+
+		$this->assertStringContainsString(
+			'/product/' . $product->get_id() . '/',
+			$permalink,
+			'Permalink should have the post ID replaced'
+		);
+		$this->assertStringNotContainsString(
+			'%post_id%',
+			$permalink,
+			'Permalink should not contain unreplaced %post_id% placeholder'
+		);
+		$this->assertStringNotContainsString(
+			'%category%',
+			$permalink,
+			'Permalink should not contain %category% placeholder'
+		);
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+		wp_delete_term( $category_term['term_id'], 'product_cat' );
+	}
 }