Commit 60c56853df for woocommerce

commit 60c56853dfd62724b52fd54df57403d902951217
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Thu Dec 11 14:22:58 2025 -0500

    Fix product permalinks to use deepest category instead of highest parent ID (#62321)

    * Fix product permalinks to use deepest category instead of highest parent ID

    Previously, product permalinks incorrectly selected categories by sorting
    on parent term ID (numeric value) rather than hierarchy depth. This caused
    products assigned to multiple categories to use a shallower category when
    a deeper one existed.

    The fix:
    - Replace wp_list_sort by parent ID with explicit depth calculation
    - Find the category with the most ancestors (deepest in hierarchy)
    - Optimize by skipping root categories and caching ancestors for reuse

    Fixes #62274

    ---------

    Co-authored-by: github-actions <github-actions@github.com>

diff --git a/plugins/woocommerce/changelog/62321-fix-WOOPLUG-5957-product-permalinks-deepest-category b/plugins/woocommerce/changelog/62321-fix-WOOPLUG-5957-product-permalinks-deepest-category
new file mode 100644
index 0000000000..c38f8aa0ed
--- /dev/null
+++ b/plugins/woocommerce/changelog/62321-fix-WOOPLUG-5957-product-permalinks-deepest-category
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix product permalinks to use deepest category instead of highest parent term ID when product is assigned to multiple categories.
\ 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 0a444db402..31e635573d 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -271,19 +271,49 @@ function wc_product_post_type_link( $permalink, $post ) {
 	// Get the custom taxonomy terms in use by this post.
 	$terms = get_the_terms( $post->ID, 'product_cat' );

-	if ( ! empty( $terms ) ) {
-		$terms           = wp_list_sort(
-			$terms,
-			array(
-				'parent'  => 'DESC',
-				'term_id' => 'ASC',
-			)
-		);
-		$category_object = apply_filters( 'wc_product_post_type_link_product_cat', $terms[0], $terms, $post );
+	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 ) {
-			$ancestors = get_ancestors( $category_object->term_id, 'product_cat' );
+			// 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 ) ) {
@@ -317,7 +347,7 @@ function wc_product_post_type_link( $permalink, $post ) {
 		date_i18n( 'H', strtotime( $post->post_date ) ),
 		date_i18n( 'i', strtotime( $post->post_date ) ),
 		date_i18n( 's', strtotime( $post->post_date ) ),
-		$post->ID,
+		(string) $post->ID,
 		$product_cat,
 		$product_cat,
 	);
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index f6e5c7a10a..935baf2257 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -52611,12 +52611,6 @@ parameters:
 			count: 1
 			path: includes/wc-product-functions.php

-		-
-			message: '#^Parameter \#1 \$input_list of function wp_list_sort expects array, array\<WP_Term\>\|WP_Error given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/wc-product-functions.php
-
 		-
 			message: '#^Parameter \#1 \$maybeint of function absint expects array\|bool\|float\|int\|resource\|string\|null, mixed given\.$#'
 			identifier: argument.type
@@ -52707,12 +52701,6 @@ parameters:
 			count: 2
 			path: includes/wc-product-functions.php

-		-
-			message: '#^Parameter \#2 \$replace of function str_replace expects array\<string\>\|string, array\<int, mixed\> given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/wc-product-functions.php
-
 		-
 			message: '#^Parameter \#2 \$str of function explode expects string, mixed given\.$#'
 			identifier: argument.type
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 32e0985a84..cba471b7ee 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -377,4 +377,114 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 		WC_Helper_Product::delete_product( $related_product2->get_id() );
 		WC_Helper_Product::delete_product( $related_product3->get_id() );
 	}
+
+	/**
+	 * @testdox Product permalink should use deepest category, not the one with highest parent term ID.
+	 */
+	public function test_wc_product_post_type_link_uses_deepest_category() {
+		/*
+		 * Reproduce the bug from WOOPLUG-5957:
+		 * Create categories "out of sequence" so term_ids don't match hierarchy depth.
+		 * Per the issue: "Level 2 ID should be higher than all other levels."
+		 *
+		 * We create Level 2 LAST so it has the highest term_id. Then we update parent
+		 * relationships. This means Level 3's parent (Level 2) has a higher term_id
+		 * than Level 4's parent (Level 3).
+		 *
+		 * Old buggy code sorted by parent DESC, so it would select Level 3 (parent=Level 2
+		 * with high term_id) instead of Level 4 (the actual deepest category).
+		 */
+
+		// Create Level 1 first (gets lowest term_id).
+		$level1_term = wp_insert_term( 'Level 1', 'product_cat' );
+
+		// Create Level 3 and Level 4 without parents initially.
+		$level3_term = wp_insert_term( 'Level 3', 'product_cat' );
+		$level4_term = wp_insert_term( 'Level 4 Deepest', 'product_cat' );
+
+		// Create Level 2 LAST (gets highest term_id).
+		$level2_term = wp_insert_term( 'Level 2', 'product_cat' );
+
+		// Set up hierarchy: Level 1 > Level 2 > Level 3 > Level 4.
+		wp_update_term( $level2_term['term_id'], 'product_cat', array( 'parent' => $level1_term['term_id'] ) );
+		wp_update_term( $level3_term['term_id'], 'product_cat', array( 'parent' => $level2_term['term_id'] ) );
+		wp_update_term( $level4_term['term_id'], 'product_cat', array( 'parent' => $level3_term['term_id'] ) );
+
+		// Assign product to all categories.
+		$product = WC_Helper_Product::create_simple_product();
+		wp_set_object_terms(
+			$product->get_id(),
+			array(
+				$level1_term['term_id'],
+				$level2_term['term_id'],
+				$level3_term['term_id'],
+				$level4_term['term_id'],
+			),
+			'product_cat'
+		);
+
+		// Set up permalink structure to include product_cat.
+		update_option( 'woocommerce_permalinks', array( 'product_base' => '/shop/%product_cat%' ) );
+		$product_post = get_post( $product->get_id() );
+
+		// Call wc_product_post_type_link directly to test the category selection.
+		$permalink = wc_product_post_type_link( '/shop/%product_cat%/' . $product_post->post_name . '/', $product_post );
+
+		// Get slugs for assertions.
+		$level1_slug = get_term( $level1_term['term_id'], 'product_cat' )->slug;
+		$level2_slug = get_term( $level2_term['term_id'], 'product_cat' )->slug;
+		$level3_slug = get_term( $level3_term['term_id'], 'product_cat' )->slug;
+		$level4_slug = get_term( $level4_term['term_id'], 'product_cat' )->slug;
+
+		// The permalink should contain the full hierarchical path of the deepest category (level 4).
+		// The old buggy code would select Level 3 (parent=Level 2 with high term_id) instead of Level 4.
+		$expected_path = $level1_slug . '/' . $level2_slug . '/' . $level3_slug . '/' . $level4_slug;
+		$this->assertStringContainsString(
+			$expected_path,
+			$permalink,
+			'Permalink should contain the full path of the deepest category (level 4)'
+		);
+
+		// Clean up (delete children before parents).
+		WC_Helper_Product::delete_product( $product->get_id() );
+		wp_delete_term( $level4_term['term_id'], 'product_cat' );
+		wp_delete_term( $level3_term['term_id'], 'product_cat' );
+		wp_delete_term( $level2_term['term_id'], 'product_cat' );
+		wp_delete_term( $level1_term['term_id'], 'product_cat' );
+	}
+
+	/**
+	 * @testdox Product permalink uses first root category when product has only root-level categories.
+	 */
+	public function test_wc_product_post_type_link_with_only_root_categories() {
+		// Create multiple root categories - first one (lowest term_id) should be selected.
+		$root1_term = wp_insert_term( 'Root Category One', 'product_cat' );
+		$root2_term = wp_insert_term( 'Root Category Two', 'product_cat' );
+		$root3_term = wp_insert_term( 'Root Category Three', 'product_cat' );
+
+		$product = WC_Helper_Product::create_simple_product();
+		wp_set_object_terms(
+			$product->get_id(),
+			array( $root1_term['term_id'], $root2_term['term_id'], $root3_term['term_id'] ),
+			'product_cat'
+		);
+
+		update_option( 'woocommerce_permalinks', array( 'product_base' => '/shop/%product_cat%' ) );
+		$product_post = get_post( $product->get_id() );
+
+		$permalink = wc_product_post_type_link( '/shop/%product_cat%/' . $product_post->post_name . '/', $product_post );
+
+		// First root category (lowest term_id) should be used.
+		$root1_slug = get_term( $root1_term['term_id'], 'product_cat' )->slug;
+		$this->assertStringContainsString(
+			'/' . $root1_slug . '/',
+			$permalink,
+			'Permalink should contain the first root category slug'
+		);
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+		wp_delete_term( $root3_term['term_id'], 'product_cat' );
+		wp_delete_term( $root2_term['term_id'], 'product_cat' );
+		wp_delete_term( $root1_term['term_id'], 'product_cat' );
+	}
 }