Commit a5507f6f1cd for woocommerce
commit a5507f6f1cdd9213aebe09a34307e6b10f877f8c
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Aug 25 14:39:44 2026 +0300
Add hierarchy ordering to product category lists (#67633)
* feat: add product category list breadcrumb ordering
Context: Product category lists in classic product meta are built through wc_get_product_category_list(), which previously delegated directly to WordPress term-list output.
Problem: WordPress term-list ordering can render assigned hierarchical categories in reverse breadcrumb order, and callers had no helper-level way to request a more logical category order.
Solution: Add an optional fifth ordering argument for breadcrumb/hierarchy and name ordering while preserving the existing default behavior, and opt the classic single-product meta template into breadcrumb ordering.
Refs #36758
* chore: add product category ordering changelog
Context: WooCommerce Core changes need package changelog entries so release notes capture merchant-facing and developer-facing updates.
Problem: The product category list ordering enhancement did not yet have a changelog file.
Solution: Add a patch tweak changelog entry describing breadcrumb ordering support for product category list output.
Refs #36758
* fix: bound product category hierarchy ordering queries
The new breadcrumb ordering mode originally hydrated each uncached ancestor and its order metadata individually. Deep or independent category branches therefore added query counts in proportion to every ancestor, while an undocumented alias expanded the public API without a distinct use case.
Discover ancestor parents one hierarchy level at a time, prime term metadata once, and sort from precomputed scalar paths. Keep the historical four-argument path unchanged, preserve WordPress term filters and error returns in ordered modes, and remove the unnecessary alias.
Strengthen coverage across independent branches, filter composition, bounded cold-cache queries, template error handling, and exact global restoration.
Refs #36758
* docs: clarify product category ordering modes
The public helper documentation named its optional modes without defining their exact behavior, leaving it unclear whether breadcrumb ordering inserts ancestors or only reorders assigned terms.\n\nClarify the natural name sort and the ancestry-path comparison rules, including category order, natural name, and term ID tie-breakers. Runtime behavior remains unchanged.
* fix: preserve product category list compatibility
wc_get_product_category_list() delegates to WordPress for its established default behavior. Unsupported ordering values must retain the same path for third-party callers.
Fetching and normalizing before fallback duplicated get_the_terms filters and let sanitize_key hooks change default calls. Filtered non-scalar order metadata could also trigger coercion warnings.
Guard supported modes before ordered work, use term-specific metadata cache priming, and normalize filtered order values. Add causal coverage for hooks and cache invalidation.
Refs #36758
* fix: correct product category return annotations
wc_get_product_category_list() now documents its existing union return, but the deprecated product wrapper still claimed it always returned a string. That mismatch exposed a PHPStan error while leaving the old helper suppression unmatched.
Document the wrapper's actual return union and remove the obsolete suppression so static analysis verifies both call sites directly.
Refs #36758
* fix: make product meta category order filterable
The stock single-product meta template opted into breadcrumb category ordering unconditionally. Sites using the current template therefore had no narrow way to retain or customize their previous category ordering.
Filter the template's ordering mode while keeping breadcrumb order as the default, and validate callback output before passing it to the category-list helper.
Refs #36758
* chore: classify product category ordering changelog
The category-list helper gains a public ordering argument, and the product meta template exposes a new ordering filter. The previous patch/tweak metadata described this as a minor adjustment even though it adds backward-compatible API functionality.\n\nUse minor/add so release tooling categorizes the entry consistently with Changelogger semantics.
* test: clarify product category ordering coverage
The query-count guard relies on regex boundary semantics that were not obvious, while the template test combined three independent contracts and could hide later failures.\n\nDocument the numeric-ID matching rule and split the template checks around shared per-test fixture setup and cleanup. This improves failure isolation without changing production behavior.
* test: simplify product meta category assertions
The template ordering checks normalized whitespace and accepted either singular or plural category labels. That hid stale category state after assigning terms behind the cached product object's back.\n\nPersist category IDs through WC_Product before rendering the template, then assert the exact plural label and ordered names. This keeps the fixture coherent while removing unnecessary regex handling.
* test: count all batched ancestor queries
The batching test's SQL matcher looked for "FROM {$wpdb->terms} AS t
INNER JOIN" with a single space. WP_Term_Query builds
"FROM $wpdb->terms AS t $join" where $join already opens with a space,
so core emits the clause with both one and two spaces and the literal
match saw only the _prime_term_caches() follow-up.
The counter therefore reported 2 where the real number is 4, and the
assertion passed for the wrong reason. Match the join clause with a
whitespace-tolerant pattern and raise the bound to the real number.
The bound stays a bound rather than an exact count: the two-queries-per-
level split is a core implementation detail, while one query per ancestor
is the regression worth guarding against.
Refs #36758
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: rank product category order by rendered terms only
Breadcrumb ordering climbed each category's full ancestry and compared
every level of it, so a category the product is not assigned to could
decide the order of the ones it is. A product in "Apparel > Zebra
Shirts" and "Books" rendered "Zebra Shirts, Books", because the
invisible "Apparel" sorted ahead of "Books".
Nothing on the page explained that order, and a merchant reordering
their categories could not act on it: the term steering the sort was not
in the output.
Compress each ancestry chain to the terms actually being rendered before
comparing. Ancestors still establish which category sits under which,
but a category absent from the output contributes no sort key of its
own, so the reason for the rendered order is always visible.
Ancestry now comes from get_ancestors() rather than a hand-built parent
walk. It honours the public get_ancestors filter, carries core's own
cycle guard, and reads the cache that the batched frontier query primes.
That query is demoted to a pure cache prime; it no longer feeds the
comparator, so a filtered get_terms() can no longer reorder the output.
The prime still pays for itself, because product_cat has a hierarchical
rewrite and get_term_link() resolves the same ancestry to build each
permalink: a product on ten depth-3 leaves renders in 5 queries cold,
against 20 on the default path.
Output is unchanged on both examples in the issue, on stores that assign
the full chain, on branch grouping, and on level skipping.
Refs #36758
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat: warn on unsupported product category ordering mode
Only 'name' and 'breadcrumb' order anything. Any other $orderby value
fell through to the unsorted WordPress term list in silence, and since
that list looks perfectly normal there was no way to tell the ordering
never ran.
WooCommerce already uses 'menu_order' and 'order' for category ordering
elsewhere in wc-term-functions.php, so those are exactly the values a
developer is likely to reach for first.
Emit _doing_it_wrong() for any non-empty unsupported value, naming the
value that was passed and the ones that work. The return value is
untouched in every case, so a four-argument call and an empty $orderby
stay silent and behave as before.
Refs #36758
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: tolerate unnamed terms in product category name ordering
Name ordering handed $a->name and $b->name straight to strnatcasecmp().
A term whose name comes back as null -- from a get_the_terms filter, or
a partially hydrated object -- makes PHP 8.1 log a deprecation on every
single comparison, so one bad term floods the log in proportion to how
many categories the product has.
Cast both operands, matching what the breadcrumb branch already does.
Null names sort as empty strings rather than raising diagnostics.
Refs #36758
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: clarify product category term-links filter handling
The term_links-product_cat docblock carried an @since 2.5.0, which reads
as a WooCommerce version inside plugins/woocommerce/. It is WordPress's
version for a core hook.
Point the docblock at core's canonical documentation instead, using the
same reference form the codebase already uses for core hooks it re-fires
elsewhere. The hook belongs to WordPress, so it carries WordPress's
documentation and no WooCommerce @since.
The ordered path also validates the filter's return value where core's
get_the_term_list() does not: a callback returning a string makes core
raise a TypeError from implode(), while this path returns false and
stays inside its documented string|false|WP_Error contract. That
asymmetry was implied by the code and is now stated, along with why
core's behaviour is left alone.
Refs #36758
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: cover category depth against term ID sequence
PR #62321 fixed a bug in this same file where term ID order stood in for
depth, and its regression test deliberately creates categories out of
order for that reason.
Every fixture here creates ancestors before their descendants, so term ID
order always agrees with depth and that whole class of bug is invisible
to the suite.
Add a fixture where the descendant is created first and named so that
alphabetical order also runs against the hierarchy. The implementation
already handles it; the test is what stops a later change from quietly
reintroducing the #62321 bug.
Refs #36758
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: retarget product category ordering to 11.2.0
Trunk moved to 11.2.0-dev in bb6fc8164705 on 2026-08-14, after this
branch was written, so the $orderby @since, the meta.php template
version, and the woocommerce_product_meta_category_orderby @since all
still pointed at a release this can no longer ship in.
The template version matters beyond documentation: themes overriding
single-product/meta.php compare against it to decide when to
resynchronize.
Refs #36758
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: describe the stock template ordering change
The entry described the new $orderby argument as an addition, which
reads as opt-in. The stock single-product/meta.php passes 'breadcrumb',
so category ordering changes on every store running the stock template,
not only on ones that call the function themselves.
Say that plainly, and name the restore path so a merchant who preferred
the old order knows what to do about it.
Refs #36758
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: close mutation gaps in category ordering coverage
Mutation testing on the ordering path found four breaks that the suite
did not notice: removing the batched prime entirely, removing the
is_numeric guards on its return value, removing the walk's level cap,
and ignoring category order metadata.
The last one hid behind a fixture. honors_filtered_category_order named
its terms 'Filtered first' and 'Filtered second' and asserted that
order, which is also their alphabetical order, so the name tiebreak
produced the expected string even with category order ignored entirely.
Rename them so the expectation runs against both alphabetical order and
the stored metas, and the test can fail for its own reason.
batches_ancestors could not cover the prime either: at two branches the
cost is the same with and without it, so its bound stayed green when the
loop was deleted. Add a six-branch fixture instead, where batching per
level and resolving per ancestor diverge, and assert the ancestor query
count does not grow with the branch count.
Add two tests for the guards. One returns WP_Term objects where the
prime asked for an id=>parent map, asserting the rendered order is
unchanged and no object-to-int conversion notice is raised. The other
feeds the walk an unseen ancestor every round, asserting it stops well
inside the cap; its filter gives up at 500 rounds so a removed cap fails
the assertion rather than hanging the run.
Refs #36758
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Update plugins/woocommerce/includes/legacy/abstract-wc-legacy-product.php
Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
* Update plugins/woocommerce/includes/wc-product-functions.php
Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
* docs: correct and rewrap product category ordering annotations
The review suggestions landed with a subject-verb disagreement -- the
default path "honor" rather than "honors" the category order termmeta.
Both also arrived as single long lines: 176 characters in the $orderby
description and 334 in the legacy @return. Neither exceeds any enforced
limit, since Generic.Files.LineLength is not in WooCommerce's ruleset,
but the $orderby block wraps its own continuation lines around 108
characters, so the added text left one ragged paragraph.
Rewrap both to match the block they sit in.
Refs #36758
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/36758-product-category-list-order b/plugins/woocommerce/changelog/36758-product-category-list-order
new file mode 100644
index 00000000000..459bed6e25c
--- /dev/null
+++ b/plugins/woocommerce/changelog/36758-product-category-list-order
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an $orderby argument to wc_get_product_category_list() and order single-product meta categories by hierarchy. Stores on the stock single-product/meta.php template now render categories ancestor-first; return an empty string from woocommerce_product_meta_category_orderby to restore WordPress term-list order.
diff --git a/plugins/woocommerce/includes/legacy/abstract-wc-legacy-product.php b/plugins/woocommerce/includes/legacy/abstract-wc-legacy-product.php
index 4b0fe302829..72d363e12b1 100644
--- a/plugins/woocommerce/includes/legacy/abstract-wc-legacy-product.php
+++ b/plugins/woocommerce/includes/legacy/abstract-wc-legacy-product.php
@@ -370,7 +370,11 @@ abstract class WC_Abstract_Legacy_Product extends WC_Data {
* @param string $sep (default: ', ').
* @param string $before (default: '').
* @param string $after (default: '').
- * @return string
+ * @return string|false|WP_Error Category list HTML on success. False when the product has no
+ * categories, and also when an ordering mode is selected and a
+ * `term_links-product_cat` callback returns a non-array; the default
+ * path passes that value to implode() the way core does. WP_Error if
+ * the terms or a term link cannot be resolved.
*/
public function get_categories( $sep = ', ', $before = '', $after = '' ) {
wc_deprecated_function( 'WC_Product::get_categories', '3.0', 'wc_get_product_category_list' );
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index 026aca1e8cd..6b31e0f2eec 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -1729,14 +1729,226 @@ function wc_get_price_to_display( $product, $args = array() ) {
/**
* Returns the product categories in a list.
*
+ * @since 11.2.0 Added the `$orderby` argument.
+ *
* @param int $product_id Product ID.
* @param string $sep (default: ', ').
* @param string $before (default: '').
* @param string $after (default: '').
- * @return string
- */
-function wc_get_product_category_list( $product_id, $sep = ', ', $before = '', $after = '' ) {
- return get_the_term_list( $product_id, 'product_cat', $before, $sep, $after );
+ * @param string $orderby Optional ordering mode. Accepts 'name' to naturally order assigned terms by name,
+ * ignoring the category order termmeta that the default path honors, or 'breadcrumb'
+ * to order assigned terms by ancestry path, comparing category order, natural name,
+ * and term ID at each level of the rendered hierarchy. Any other value preserves
+ * WordPress term-list order; a non-empty unsupported value also triggers a
+ * `_doing_it_wrong()` notice. Default empty string.
+ * @return string|false|WP_Error
+ */
+function wc_get_product_category_list( $product_id, $sep = ', ', $before = '', $after = '', $orderby = '' ) {
+ if ( ! in_array( $orderby, array( 'name', 'breadcrumb' ), true ) ) {
+ if ( '' !== $orderby ) {
+ wc_doing_it_wrong(
+ __FUNCTION__,
+ sprintf(
+ /* translators: 1: the unsupported ordering mode that was passed. */
+ __( 'Unsupported $orderby value "%1$s". Use "name", "breadcrumb", or an empty string for WordPress term-list order.', 'woocommerce' ),
+ is_scalar( $orderby ) ? (string) $orderby : gettype( $orderby )
+ ),
+ '11.2.0'
+ );
+ }
+
+ return get_the_term_list( $product_id, 'product_cat', $before, $sep, $after );
+ }
+
+ $terms = get_the_terms( $product_id, 'product_cat' );
+
+ if ( is_wp_error( $terms ) ) {
+ return $terms;
+ }
+
+ if ( empty( $terms ) || ! is_array( $terms ) ) {
+ return false;
+ }
+
+ $terms = array_values( $terms );
+ $should_sort = 1 < count( $terms );
+
+ if ( 'name' === $orderby && $should_sort ) {
+ usort(
+ $terms,
+ function ( $a, $b ) {
+ $name_comparison = strnatcasecmp( (string) $a->name, (string) $b->name );
+
+ return 0 !== $name_comparison ? $name_comparison : $a->term_id <=> $b->term_id;
+ }
+ );
+ } elseif ( 'breadcrumb' === $orderby && $should_sort ) {
+ $rendered_terms = array();
+
+ foreach ( $terms as $term ) {
+ $rendered_terms[ (int) $term->term_id ] = $term;
+ }
+
+ /*
+ * Resolve ancestors one batched query per hierarchy level rather than one query per
+ * ancestor. The batch is a cache prime, not the ordering data: get_ancestors() below reads
+ * it, and so does get_term_link() further down, because product_cat is registered with a
+ * hierarchical rewrite and therefore resolves each category's ancestry to build its
+ * permalink. Priming here makes the deep leaf-only case cheaper than not ordering at all.
+ */
+ $known_ids = array_fill_keys( array_keys( $rendered_terms ), true );
+ $frontier = array();
+
+ foreach ( $terms as $term ) {
+ if ( $term->parent ) {
+ $frontier[ (int) $term->parent ] = true;
+ }
+ }
+
+ // Ancestry is filterable, so bound the walk rather than trusting it to terminate.
+ $remaining_levels = 100;
+
+ while ( $frontier && $remaining_levels-- > 0 ) {
+ $frontier_ids = array_values( array_diff( array_keys( $frontier ), array_keys( $known_ids ) ) );
+
+ if ( ! $frontier_ids ) {
+ break;
+ }
+
+ $frontier_parents = get_terms(
+ array(
+ 'taxonomy' => 'product_cat',
+ 'include' => $frontier_ids,
+ 'hide_empty' => false,
+ 'fields' => 'id=>parent',
+ 'orderby' => 'include',
+ 'update_term_meta_cache' => false,
+ )
+ );
+
+ /*
+ * A filtered get_terms() can return any shape. Failing to prime is harmless; the
+ * get_ancestors() calls below just query for themselves.
+ */
+ if ( is_wp_error( $frontier_parents ) || ! is_array( $frontier_parents ) ) {
+ break;
+ }
+
+ $frontier = array();
+
+ foreach ( $frontier_parents as $term_id => $parent_id ) {
+ if ( ! is_numeric( $term_id ) || ! is_numeric( $parent_id ) ) {
+ continue;
+ }
+
+ $known_ids[ (int) $term_id ] = true;
+
+ if ( (int) $parent_id ) {
+ $frontier[ (int) $parent_id ] = true;
+ }
+ }
+ }
+
+ update_termmeta_cache( array_keys( $rendered_terms ) );
+
+ $term_orders = array();
+ $term_paths = array();
+
+ foreach ( $terms as $term ) {
+ $term_id = (int) $term->term_id;
+ $term_order = get_term_meta( $term_id, 'order', true );
+
+ $term_orders[ $term_id ] = is_numeric( $term_order ) ? (int) $term_order : 0;
+
+ $ancestor_ids = array_filter( (array) get_ancestors( $term_id, 'product_cat', 'taxonomy' ), 'is_numeric' );
+ $ancestor_ids = array_map( 'intval', $ancestor_ids );
+ // Cyclic ancestry can list the term itself among its own ancestors.
+ $ancestor_ids = array_diff( array_unique( $ancestor_ids ), array( $term_id ) );
+
+ $path = array_reverse( array_values( $ancestor_ids ) );
+ $path[] = $term_id;
+
+ /*
+ * Rank on rendered terms only. Ancestors establish which term sits under which, but a
+ * category that is not in the output never contributes a sort key of its own, so the
+ * reason for the rendered order is always visible on the page.
+ */
+ $term_paths[ $term_id ] = array_values(
+ array_filter(
+ $path,
+ static function ( $step ) use ( $rendered_terms ) {
+ return isset( $rendered_terms[ $step ] );
+ }
+ )
+ );
+ }
+
+ usort(
+ $terms,
+ static function ( $a, $b ) use ( $rendered_terms, $term_orders, $term_paths ) {
+ $a_path = $term_paths[ (int) $a->term_id ];
+ $b_path = $term_paths[ (int) $b->term_id ];
+ $shared_depth = min( count( $a_path ), count( $b_path ) );
+
+ for ( $index = 0; $index < $shared_depth; $index++ ) {
+ $a_term_id = $a_path[ $index ];
+ $b_term_id = $b_path[ $index ];
+
+ if ( $a_term_id === $b_term_id ) {
+ continue;
+ }
+
+ if ( $term_orders[ $a_term_id ] !== $term_orders[ $b_term_id ] ) {
+ return $term_orders[ $a_term_id ] <=> $term_orders[ $b_term_id ];
+ }
+
+ $path_comparison = strnatcasecmp(
+ (string) $rendered_terms[ $a_term_id ]->name,
+ (string) $rendered_terms[ $b_term_id ]->name
+ );
+
+ return 0 !== $path_comparison ? $path_comparison : $a_term_id <=> $b_term_id;
+ }
+
+ return count( $a_path ) <=> count( $b_path );
+ }
+ );
+ }
+
+ $links = array();
+
+ foreach ( $terms as $term ) {
+ $link = get_term_link( $term, 'product_cat' );
+
+ if ( is_wp_error( $link ) ) {
+ return $link;
+ }
+
+ $links[] = '<a href="' . esc_url( $link ) . '" rel="tag">' . $term->name . '</a>';
+ }
+
+ /*
+ * Fire core's own term_links-{$taxonomy} hook, so that ordered output stays filterable by exactly
+ * what already filters get_the_term_list().
+ */
+ /** This filter is documented in wp-includes/category-template.php */
+ $term_links = apply_filters( 'term_links-product_cat', $links ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores, WooCommerce.Commenting.CommentHooks.MissingSinceComment
+
+ /*
+ * Validate what the filter handed back. Core's own path does not, and passing a non-array to
+ * implode() there raises a TypeError. Returning false keeps this path within the documented
+ * string|false|WP_Error contract instead; core's behaviour is deliberately left alone, since
+ * changing it would be a separate compatibility decision.
+ */
+ if ( is_wp_error( $term_links ) ) {
+ return $term_links;
+ }
+
+ if ( ! is_array( $term_links ) ) {
+ return false;
+ }
+
+ return $before . implode( $sep, $term_links ) . $after;
}
/**
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 1a748176170..57a84f5f571 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -35013,12 +35013,6 @@ parameters:
count: 1
path: includes/wc-product-functions.php
- -
- message: '#^Function wc_get_product_category_list\(\) should return string but returns string\|WP_Error\|false\.$#'
- identifier: return.type
- count: 1
- path: includes/wc-product-functions.php
-
-
message: '#^Function wc_get_product_object\(\) should return WC_Product but returns object\.$#'
identifier: return.type
diff --git a/plugins/woocommerce/templates/single-product/meta.php b/plugins/woocommerce/templates/single-product/meta.php
index 845ac3ae8b2..4ddd7b20fa1 100644
--- a/plugins/woocommerce/templates/single-product/meta.php
+++ b/plugins/woocommerce/templates/single-product/meta.php
@@ -12,7 +12,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates
- * @version 9.7.0
+ * @version 11.2.0
*/
use Automattic\WooCommerce\Enums\ProductType;
@@ -33,7 +33,26 @@ global $product;
<?php endif; ?>
- <?php echo wc_get_product_category_list( $product->get_id(), ', ', '<span class="posted_in">' . _n( 'Category:', 'Categories:', count( $product->get_category_ids() ), 'woocommerce' ) . ' ', '</span>' ); ?>
+ <?php
+ /**
+ * Filters the ordering mode for product categories in the single product meta.
+ *
+ * @param string $orderby Ordering mode passed to wc_get_product_category_list().
+ * @param WC_Product $product Product object.
+ *
+ * @since 11.2.0
+ */
+ $product_category_orderby = apply_filters( 'woocommerce_product_meta_category_orderby', 'breadcrumb', $product );
+
+ $product_category_list = wc_get_product_category_list(
+ $product->get_id(),
+ ', ',
+ '<span class="posted_in">' . _n( 'Category:', 'Categories:', count( $product->get_category_ids() ), 'woocommerce' ) . ' ',
+ '</span>',
+ is_string( $product_category_orderby ) ? $product_category_orderby : ''
+ );
+ echo is_string( $product_category_list ) ? $product_category_list : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ ?>
<?php echo wc_get_product_tag_list( $product->get_id(), ', ', '<span class="tagged_as">' . _n( 'Tag:', 'Tags:', count( $product->get_tag_ids() ), 'woocommerce' ) . ' ', '</span>' ); ?>
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 d475f8f5d5a..8d7d1bfbc95 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -722,8 +722,10 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
* @testDox Action Scheduler events are scheduled when product with sale dates is saved.
*/
public function test_wc_schedule_product_sale_events_on_save() {
- $future_start = time() + 3600; // 1 hour from now.
- $future_end = time() + 86400; // 24 hours from now.
+ $future_start = time() + 3600;
+ // 1 hour from now.
+ $future_end = time() + 86400;
+ // 24 hours from now.
$product = WC_Helper_Product::create_simple_product();
$product->set_price( 100 );
@@ -771,7 +773,8 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
);
// Update the sale dates.
- $new_start = time() + 7200; // 2 hours from now.
+ $new_start = time() + 7200;
+ // 2 hours from now.
$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', $new_start ) );
$product->save();
@@ -1113,7 +1116,8 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
// Create a guest order with French billing address.
$order = wc_create_order();
- $order->set_customer_id( 0 ); // Guest order.
+ $order->set_customer_id( 0 );
+ // Guest order.
$order->set_billing_country( 'FR' );
$order->set_billing_city( 'Paris' );
$order->set_billing_postcode( '75001' );
@@ -1194,6 +1198,839 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
WC_Helper_Product::delete_product( $related_product3->get_id() );
}
+ /**
+ * @testdox Product category list preserves WordPress term-list order by default.
+ */
+ public function test_wc_get_product_category_list_preserves_default_order(): void {
+ $suffix = wp_unique_id();
+ $root = wp_insert_term( 'Default Root ' . $suffix, 'product_cat' );
+ $child = wp_insert_term( 'Default Child ' . $suffix, 'product_cat', array( 'parent' => $root['term_id'] ) );
+ $product = WC_Helper_Product::create_simple_product();
+ $get_the_terms_filter = null;
+ $sanitize_key_filter = null;
+
+ try {
+ wp_set_object_terms( $product->get_id(), array( $root['term_id'], $child['term_id'] ), 'product_cat' );
+
+ $expected = get_the_term_list( $product->get_id(), 'product_cat', 'Before ', ' > ', ' After' );
+ $sanitize_key_calls = 0;
+ $sanitize_key_filter = static function ( $sanitized_key, $key ) use ( &$sanitize_key_calls ) {
+ if ( '' === $key ) {
+ ++$sanitize_key_calls;
+
+ return 'breadcrumb';
+ }
+
+ return $sanitized_key;
+ };
+ add_filter( 'sanitize_key', $sanitize_key_filter, 10, 2 );
+
+ $actual = wc_get_product_category_list( $product->get_id(), ' > ', 'Before ', ' After' );
+
+ remove_filter( 'sanitize_key', $sanitize_key_filter );
+ $sanitize_key_filter = null;
+
+ $this->assertSame( $expected, $actual, 'Default helper output should remain identical to WordPress term-list output.' );
+ $this->assertSame( 0, $sanitize_key_calls, 'Default helper calls should not introduce ordering-mode sanitization hooks.' );
+
+ $get_the_terms_calls = 0;
+ $get_the_terms_filter = static function ( $terms, $post_id, $taxonomy ) use ( &$get_the_terms_calls, $product ) {
+ if ( $product->get_id() === $post_id && 'product_cat' === $taxonomy ) {
+ ++$get_the_terms_calls;
+ }
+
+ return $terms;
+ };
+ add_filter( 'get_the_terms', $get_the_terms_filter, 10, 3 );
+
+ $this->setExpectedIncorrectUsage( 'wc_get_product_category_list' );
+
+ $this->assertSame(
+ $expected,
+ wc_get_product_category_list( $product->get_id(), ' > ', 'Before ', ' After', 'hierarchy' ),
+ 'Unsupported ordering modes should fall back to WordPress term-list output.'
+ );
+ $this->assertSame( 1, $get_the_terms_calls, 'Unsupported ordering modes should invoke the WordPress term-list path only once.' );
+ } finally {
+ if ( null !== $sanitize_key_filter ) {
+ remove_filter( 'sanitize_key', $sanitize_key_filter );
+ }
+ if ( null !== $get_the_terms_filter ) {
+ remove_filter( 'get_the_terms', $get_the_terms_filter );
+ }
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $child['term_id'], 'product_cat' );
+ wp_delete_term( $root['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list warns about an unsupported ordering mode and still falls back to WordPress order.
+ */
+ public function test_wc_get_product_category_list_warns_on_unsupported_orderby(): void {
+ $suffix = wp_unique_id();
+ $category = wp_insert_term( 'Unsupported orderby ' . $suffix, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+
+ try {
+ wp_set_object_terms( $product->get_id(), array( $category['term_id'] ), 'product_cat' );
+
+ $expected = get_the_term_list( $product->get_id(), 'product_cat', '', ', ', '' );
+
+ $this->setExpectedIncorrectUsage( 'wc_get_product_category_list' );
+
+ $this->assertSame(
+ $expected,
+ wc_get_product_category_list( $product->get_id(), ', ', '', '', 'menu_order' ),
+ 'An unsupported ordering mode should still return the WordPress term-list output.'
+ );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $category['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list can render assigned terms in breadcrumb order.
+ */
+ public function test_wc_get_product_category_list_can_render_breadcrumb_order(): void {
+ $suffix = wp_unique_id();
+ $root_name = 'Breadcrumb Root ' . $suffix;
+ $mid_name = 'Breadcrumb Mid ' . $suffix;
+ $leaf_name = 'Breadcrumb Leaf ' . $suffix;
+ $root = wp_insert_term( $root_name, 'product_cat' );
+ $mid = wp_insert_term( $mid_name, 'product_cat', array( 'parent' => $root['term_id'] ) );
+ $leaf = wp_insert_term( $leaf_name, 'product_cat', array( 'parent' => $mid['term_id'] ) );
+ $product = WC_Helper_Product::create_simple_product();
+
+ try {
+ wp_set_object_terms( $product->get_id(), array( $leaf['term_id'], $root['term_id'], $mid['term_id'] ), 'product_cat' );
+
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ $this->assertSame( "{$root_name} > {$mid_name} > {$leaf_name}", $actual );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $leaf['term_id'], 'product_cat' );
+ wp_delete_term( $mid['term_id'], 'product_cat' );
+ wp_delete_term( $root['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering follows depth even when term IDs run against it.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_ignores_term_id_sequence(): void {
+ $suffix = wp_unique_id();
+ $leaf_name = 'Aaa descendant ' . $suffix;
+ $root_name = 'Zzz ancestor ' . $suffix;
+
+ /*
+ * Create the descendant first, so its term ID is lower than its eventual ancestor's, and
+ * name it so that alphabetical order also runs against the hierarchy.
+ */
+ $leaf = wp_insert_term( $leaf_name, 'product_cat' );
+ $root = wp_insert_term( $root_name, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+
+ try {
+ wp_update_term( $leaf['term_id'], 'product_cat', array( 'parent' => $root['term_id'] ) );
+
+ $this->assertGreaterThan(
+ $leaf['term_id'],
+ $root['term_id'],
+ 'The fixture is only meaningful while the ancestor carries the higher term ID.'
+ );
+
+ wp_set_object_terms( $product->get_id(), array( $leaf['term_id'], $root['term_id'] ), 'product_cat' );
+
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ $this->assertSame( "{$root_name} > {$leaf_name}", $actual );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $leaf['term_id'], 'product_cat' );
+ wp_delete_term( $root['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering batches ancestor loading and ignores the order of ancestors it does not render.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_batches_ancestors(): void {
+ global $wpdb;
+
+ $suffix = wp_unique_id();
+ $first_branch_name = 'First branch child ' . $suffix;
+ $second_sibling_name = 'Zulu ordered first ' . $suffix;
+ $third_sibling_name = 'Alpha ordered second ' . $suffix;
+ $first_root = wp_insert_term( 'First branch root ' . $suffix, 'product_cat' );
+ $second_root = wp_insert_term( 'Second branch root ' . $suffix, 'product_cat' );
+ $first_middle = wp_insert_term( 'First branch middle ' . $suffix, 'product_cat', array( 'parent' => $first_root['term_id'] ) );
+ $second_middle = wp_insert_term( 'Second branch middle ' . $suffix, 'product_cat', array( 'parent' => $second_root['term_id'] ) );
+ $first_branch = wp_insert_term( $first_branch_name, 'product_cat', array( 'parent' => $first_middle['term_id'] ) );
+ $third_sibling = wp_insert_term( $third_sibling_name, 'product_cat', array( 'parent' => $second_middle['term_id'] ) );
+ $second_sibling = wp_insert_term( $second_sibling_name, 'product_cat', array( 'parent' => $second_middle['term_id'] ) );
+ $product = WC_Helper_Product::create_simple_product();
+ $query_filter = null;
+
+ try {
+ update_term_meta( $first_root['term_id'], 'order', 2 );
+ update_term_meta( $second_root['term_id'], 'order', 1 );
+ update_term_meta( $second_sibling['term_id'], 'order', 1 );
+ update_term_meta( $third_sibling['term_id'], 'order', 2 );
+ wp_set_object_terms( $product->get_id(), array( $first_branch['term_id'], $third_sibling['term_id'], $second_sibling['term_id'] ), 'product_cat' );
+
+ get_the_terms( $product->get_id(), 'product_cat' );
+ $ancestor_ids = array( $first_root['term_id'], $second_root['term_id'], $first_middle['term_id'], $second_middle['term_id'] );
+
+ foreach ( $ancestor_ids as $ancestor_id ) {
+ wp_cache_delete( $ancestor_id, 'terms' );
+ wp_cache_delete( $ancestor_id, 'term_meta' );
+ }
+
+ // Match ancestor term IDs in captured SQL. Word boundaries avoid partial matches within larger numeric IDs.
+ $ancestor_id_pattern = '/\b(?:' . implode( '|', $ancestor_ids ) . ')\b/';
+
+ /*
+ * Match the term-join clause regardless of run-length whitespace. WP_Term_Query builds
+ * "FROM $wpdb->terms AS t $join" and $join already opens with a space, so core emits
+ * this clause with both one and two spaces. A literal single-space match silently sees
+ * only half of the queries.
+ */
+ $term_join_pattern = '/FROM\s+' . preg_quote( $wpdb->terms, '/' ) . '\s+AS\s+t\s+INNER\s+JOIN\s+' . preg_quote( $wpdb->term_taxonomy, '/' ) . '\s+AS\s+tt/i';
+
+ $ancestor_term_queries = 0;
+ $ancestor_meta_queries = 0;
+ $query_filter = static function ( $query ) use ( &$ancestor_meta_queries, &$ancestor_term_queries, $ancestor_id_pattern, $term_join_pattern, $wpdb ) {
+ if ( preg_match( $ancestor_id_pattern, $query ) ) {
+ if ( preg_match( $term_join_pattern, $query ) ) {
+ ++$ancestor_term_queries;
+ } elseif ( false !== strpos( $query, "FROM {$wpdb->termmeta}" ) ) {
+ ++$ancestor_meta_queries;
+ }
+ }
+
+ return $query;
+ };
+ add_filter( 'query', $query_filter );
+
+ try {
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+ } finally {
+ remove_filter( 'query', $query_filter );
+ $query_filter = null;
+ }
+
+ /*
+ * The two roots carry deliberately contradictory `order` metas (2 and 1) and neither is
+ * assigned to the product. The rendered order must come from the three assigned terms'
+ * own `order` metas -- 0, 1 and 2 -- and never from their invisible roots.
+ */
+ $this->assertSame( "{$first_branch_name} > {$second_sibling_name} > {$third_sibling_name}", $actual );
+
+ /*
+ * Two ancestor levels, and core spends two queries on each: one WP_Term_Query for the
+ * ids and one _prime_term_caches() follow-up for the rows. The bound is what guards
+ * against regressing to one query per ancestor; it is deliberately not assertSame(),
+ * because the 2-per-level split is a core implementation detail.
+ */
+ $this->assertLessThanOrEqual( 4, $ancestor_term_queries, 'Ancestor terms should be loaded in a bounded number of batched queries per hierarchy level, not one query per ancestor.' );
+ $this->assertLessThanOrEqual( 1, $ancestor_meta_queries, 'Ancestor metadata should be primed in one query.' );
+ } finally {
+ if ( null !== $query_filter ) {
+ remove_filter( 'query', $query_filter );
+ }
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $second_sibling['term_id'], 'product_cat' );
+ wp_delete_term( $third_sibling['term_id'], 'product_cat' );
+ wp_delete_term( $first_branch['term_id'], 'product_cat' );
+ wp_delete_term( $second_middle['term_id'], 'product_cat' );
+ wp_delete_term( $first_middle['term_id'], 'product_cat' );
+ wp_delete_term( $second_root['term_id'], 'product_cat' );
+ wp_delete_term( $first_root['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering never ranks by a category it does not render.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_ignores_unrendered_ancestors(): void {
+ $suffix = wp_unique_id();
+ $parent_name = 'Apparel ' . $suffix;
+ $child_name = 'Zebra shirts ' . $suffix;
+ $sibling_name = 'Books ' . $suffix;
+ $parent = wp_insert_term( $parent_name, 'product_cat' );
+ $child = wp_insert_term( $child_name, 'product_cat', array( 'parent' => $parent['term_id'] ) );
+ $sibling = wp_insert_term( $sibling_name, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+
+ try {
+ // Only the leaf and the independent root are assigned; the parent is never rendered.
+ wp_set_object_terms( $product->get_id(), array( $child['term_id'], $sibling['term_id'] ), 'product_cat' );
+
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ $this->assertSame(
+ "{$sibling_name} > {$child_name}",
+ $actual,
+ 'An ancestor that is not rendered must not decide the order of the categories that are.'
+ );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $child['term_id'], 'product_cat' );
+ wp_delete_term( $sibling['term_id'], 'product_cat' );
+ wp_delete_term( $parent['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering keeps ancestor loading flat as branch count grows.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_keeps_ancestor_queries_flat(): void {
+ global $wpdb;
+
+ $suffix = wp_unique_id();
+ $branch_count = 6;
+ $created = array();
+ $leaves = array();
+ $ancestor_ids = array();
+ $product = WC_Helper_Product::create_simple_product();
+ $query_filter = null;
+
+ /*
+ * Six branches rather than the two the sibling test uses. Ancestor loading is batched per
+ * hierarchy level, so its cost tracks depth and stays flat as branches are added; resolving
+ * one ancestor at a time instead would grow with the branch count. Two branches is too few
+ * to tell those apart -- both cost the same there.
+ */
+ try {
+ for ( $branch = 0; $branch < $branch_count; $branch++ ) {
+ $parent = 0;
+
+ for ( $depth = 0; $depth < 3; $depth++ ) {
+ $term = wp_insert_term( "Flat b{$branch} d{$depth} {$suffix}", 'product_cat', $parent ? array( 'parent' => $parent ) : array() );
+ $created[] = $term['term_id'];
+ $parent = $term['term_id'];
+
+ if ( $depth < 2 ) {
+ $ancestor_ids[] = $term['term_id'];
+ }
+ }
+
+ $leaves[] = $parent;
+ }
+
+ wp_set_object_terms( $product->get_id(), $leaves, 'product_cat' );
+ get_the_terms( $product->get_id(), 'product_cat' );
+
+ foreach ( $ancestor_ids as $ancestor_id ) {
+ wp_cache_delete( $ancestor_id, 'terms' );
+ wp_cache_delete( $ancestor_id, 'term_meta' );
+ }
+
+ $ancestor_id_pattern = '/\b(?:' . implode( '|', $ancestor_ids ) . ')\b/';
+ $term_join_pattern = '/FROM\s+' . preg_quote( $wpdb->terms, '/' ) . '\s+AS\s+t\s+INNER\s+JOIN\s+' . preg_quote( $wpdb->term_taxonomy, '/' ) . '\s+AS\s+tt/i';
+
+ $ancestor_term_queries = 0;
+ $query_filter = static function ( $query ) use ( &$ancestor_term_queries, $ancestor_id_pattern, $term_join_pattern ) {
+ if ( preg_match( $ancestor_id_pattern, $query ) && preg_match( $term_join_pattern, $query ) ) {
+ ++$ancestor_term_queries;
+ }
+
+ return $query;
+ };
+ add_filter( 'query', $query_filter );
+
+ try {
+ wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' );
+ } finally {
+ remove_filter( 'query', $query_filter );
+ $query_filter = null;
+ }
+
+ /*
+ * Two levels of ancestors, two queries each. The bound is per level, so it must not move
+ * when branch_count does -- that is the property under test.
+ */
+ $this->assertLessThanOrEqual(
+ 4,
+ $ancestor_term_queries,
+ "Ancestor loading should stay flat across {$branch_count} branches, not grow with them."
+ );
+ } finally {
+ if ( null !== $query_filter ) {
+ remove_filter( 'query', $query_filter );
+ }
+ WC_Helper_Product::delete_product( $product->get_id() );
+
+ foreach ( array_reverse( $created ) as $term_id ) {
+ wp_delete_term( $term_id, 'product_cat' );
+ }
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering survives a get_terms filter returning an unexpected shape.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_survives_hostile_get_terms_filter(): void {
+ $suffix = wp_unique_id();
+ $root_name = 'Hostile root ' . $suffix;
+ $leaf_name = 'Hostile alpha leaf ' . $suffix;
+ $other_name = 'Hostile zulu other ' . $suffix;
+ $root = wp_insert_term( $root_name, 'product_cat' );
+ $leaf = wp_insert_term( $leaf_name, 'product_cat', array( 'parent' => $root['term_id'] ) );
+ $other = wp_insert_term( $other_name, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+
+ /*
+ * The prime asks for 'id=>parent'. Any plugin filtering get_terms can hand back WP_Term
+ * objects instead, and casting one of those to int yields 1, which would build a fabricated
+ * parent chain out of whatever term happens to hold that ID.
+ */
+ $object_shape_filter = static function ( $terms, $taxonomies, $args ) use ( $root ) {
+ if ( 'id=>parent' === ( $args['fields'] ?? '' ) ) {
+ return array( get_term( $root['term_id'], 'product_cat' ) );
+ }
+
+ return $terms;
+ };
+
+ try {
+ /*
+ * The root is deliberately left unassigned. Priming only runs for ancestors that are not
+ * themselves rendered, so assigning it would leave the frontier empty and skip the code
+ * under test entirely.
+ */
+ wp_set_object_terms( $product->get_id(), array( $leaf['term_id'], $other['term_id'] ), 'product_cat' );
+
+ $expected = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ add_filter( 'get_terms', $object_shape_filter, 10, 3 );
+
+ $diagnostics = array();
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- The object-to-int conversion notice is what is being asserted on; PHPUnit would convert it to an exception and hide the ordering result.
+ set_error_handler(
+ static function ( $errno, $errstr ) use ( &$diagnostics ) {
+ $diagnostics[] = $errstr;
+
+ return true;
+ },
+ E_DEPRECATED | E_WARNING | E_NOTICE
+ );
+
+ try {
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+ } finally {
+ restore_error_handler();
+ remove_filter( 'get_terms', $object_shape_filter, 10 );
+ }
+
+ $this->assertSame( "{$leaf_name} > {$other_name}", $expected );
+ $this->assertSame( $expected, $actual, 'A get_terms filter returning term objects must not reorder the rendered categories.' );
+ $this->assertSame( array(), $diagnostics, 'Priming should reject an unexpected get_terms shape rather than trying to convert it.' );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $leaf['term_id'], 'product_cat' );
+ wp_delete_term( $other['term_id'], 'product_cat' );
+ wp_delete_term( $root['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering terminates when a get_terms filter never stops yielding ancestors.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_terminates_on_endless_ancestry(): void {
+ $suffix = wp_unique_id();
+ $root_name = 'Endless root ' . $suffix;
+ $leaf_name = 'Endless alpha leaf ' . $suffix;
+ $other_name = 'Endless zulu other ' . $suffix;
+ $root = wp_insert_term( $root_name, 'product_cat' );
+ $leaf = wp_insert_term( $leaf_name, 'product_cat', array( 'parent' => $root['term_id'] ) );
+ $other = wp_insert_term( $other_name, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $rounds = 0;
+ $next_term_id = 900000;
+
+ /*
+ * Yields a parent nobody has seen on every round, so the frontier never empties on its own,
+ * then gives up well past the implementation's ceiling. The give-up point is what keeps a
+ * removed ceiling a clean assertion failure instead of a hung test run.
+ */
+ $endless_filter = static function ( $terms, $taxonomies, $args ) use ( &$rounds, &$next_term_id ) {
+ if ( 'id=>parent' !== ( $args['fields'] ?? '' ) || $rounds >= 500 ) {
+ return $terms;
+ }
+
+ ++$rounds;
+ ++$next_term_id;
+
+ return array( $next_term_id => $next_term_id + 1 );
+ };
+
+ try {
+ // As above, the root stays unassigned so the walk has an ancestor to chase at all.
+ wp_set_object_terms( $product->get_id(), array( $leaf['term_id'], $other['term_id'] ), 'product_cat' );
+ add_filter( 'get_terms', $endless_filter, 10, 3 );
+
+ try {
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+ } finally {
+ remove_filter( 'get_terms', $endless_filter, 10 );
+ }
+
+ $this->assertGreaterThan( 0, $rounds, 'The fixture is only meaningful while the ancestor walk actually runs.' );
+ $this->assertLessThanOrEqual( 100, $rounds, 'The ancestor walk must be bounded rather than trusting a filter to end it.' );
+ $this->assertSame( "{$leaf_name} > {$other_name}", $actual, 'A bounded walk should still render the rendered terms in order.' );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $leaf['term_id'], 'product_cat' );
+ wp_delete_term( $other['term_id'], 'product_cat' );
+ wp_delete_term( $root['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering honors filtered category order after priming term metadata.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_honors_filtered_category_order(): void {
+ $suffix = wp_unique_id();
+
+ /*
+ * Named so the expected order runs against alphabetical order, and against the stored `order`
+ * metas below. Without that, an implementation that ignored category order entirely would
+ * still produce this exact string from the name tiebreak, and the test could not fail.
+ */
+ $filtered_first_name = 'Zulu filtered first ' . $suffix;
+ $filtered_second_name = 'Alpha filtered second ' . $suffix;
+ $filtered_first = wp_insert_term( $filtered_first_name, 'product_cat' );
+ $filtered_second = wp_insert_term( $filtered_second_name, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $metadata_cache_filter = static function () {
+ return true;
+ };
+ $term_metadata_filter = static function ( $value, $object_id, $meta_key ) use ( $filtered_first, $filtered_second ) {
+ if ( 'order' !== $meta_key ) {
+ return $value;
+ }
+
+ if ( $filtered_first['term_id'] === $object_id ) {
+ return 1;
+ }
+
+ return $filtered_second['term_id'] === $object_id ? 2 : $value;
+ };
+
+ try {
+ update_term_meta( $filtered_first['term_id'], 'order', 2 );
+ update_term_meta( $filtered_second['term_id'], 'order', 1 );
+ wp_set_object_terms( $product->get_id(), array( $filtered_second['term_id'], $filtered_first['term_id'] ), 'product_cat' );
+ add_filter( 'get_term_metadata', $term_metadata_filter, 10, 3 );
+
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ $this->assertSame( "{$filtered_first_name} > {$filtered_second_name}", $actual );
+
+ wp_cache_delete( $filtered_first['term_id'], 'term_meta' );
+ wp_cache_delete( $filtered_second['term_id'], 'term_meta' );
+ add_filter( 'update_term_metadata_cache', $metadata_cache_filter );
+
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ $this->assertSame( "{$filtered_first_name} > {$filtered_second_name}", $actual, 'Filtered category order should remain authoritative when cache priming is short-circuited.' );
+ } finally {
+ remove_filter( 'update_term_metadata_cache', $metadata_cache_filter );
+ remove_filter( 'get_term_metadata', $term_metadata_filter );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $filtered_second['term_id'], 'product_cat' );
+ wp_delete_term( $filtered_first['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering reflects category order updates after term metadata is primed.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_reflects_category_order_updates(): void {
+ $first_name = 'Cache order first ' . wp_unique_id();
+ $second_name = 'Cache order second ' . wp_unique_id();
+ $first = wp_insert_term( $first_name, 'product_cat' );
+ $second = wp_insert_term( $second_name, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+
+ try {
+ update_term_meta( $first['term_id'], 'order', 1 );
+ update_term_meta( $second['term_id'], 'order', 2 );
+ wp_set_object_terms( $product->get_id(), array( $second['term_id'], $first['term_id'] ), 'product_cat' );
+
+ $initial = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ update_term_meta( $first['term_id'], 'order', 2 );
+ update_term_meta( $second['term_id'], 'order', 1 );
+
+ $updated = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ $this->assertSame( "{$first_name} > {$second_name}", $initial );
+ $this->assertSame( "{$second_name} > {$first_name}", $updated );
+ } finally {
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $second['term_id'], 'product_cat' );
+ wp_delete_term( $first['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering treats invalid filtered category order as zero.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_handles_invalid_filtered_category_order(): void {
+ $suffix = wp_unique_id();
+ $invalid_order_name = 'Alpha invalid order ' . $suffix;
+ $zero_order_name = 'Zulu zero order ' . $suffix;
+ $invalid_order = wp_insert_term( $invalid_order_name, 'product_cat' );
+ $zero_order = wp_insert_term( $zero_order_name, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $term_metadata_filter = static function ( $value, $object_id, $meta_key ) use ( $invalid_order, $zero_order ) {
+ if ( 'order' !== $meta_key ) {
+ return $value;
+ }
+
+ if ( $invalid_order['term_id'] === $object_id ) {
+ return new stdClass();
+ }
+
+ return $zero_order['term_id'] === $object_id ? 0 : $value;
+ };
+
+ try {
+ wp_set_object_terms( $product->get_id(), array( $zero_order['term_id'], $invalid_order['term_id'] ), 'product_cat' );
+ add_filter( 'get_term_metadata', $term_metadata_filter, 10, 3 );
+
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ $this->assertSame( "{$invalid_order_name} > {$zero_order_name}", $actual );
+ } finally {
+ remove_filter( 'get_term_metadata', $term_metadata_filter );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $zero_order['term_id'], 'product_cat' );
+ wp_delete_term( $invalid_order['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering treats a term with a missing ancestor as a root term.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_handles_missing_ancestor(): void {
+ $suffix = wp_unique_id();
+ $missing_root = wp_insert_term( 'Missing root ' . $suffix, 'product_cat' );
+ $orphan_name = 'Zulu orphan ' . $suffix;
+ $independent_name = 'Alpha independent ' . $suffix;
+ $orphan = wp_insert_term( $orphan_name, 'product_cat', array( 'parent' => $missing_root['term_id'] ) );
+ $independent = wp_insert_term( $independent_name, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $ancestors_filter = static function ( $ancestors, $object_id, $object_type ) use ( &$orphan ) {
+ return 'product_cat' === $object_type && (int) $orphan['term_id'] === (int) $object_id ? array() : $ancestors;
+ };
+
+ try {
+ update_term_meta( $independent['term_id'], 'order', 1 );
+ update_term_meta( $orphan['term_id'], 'order', 2 );
+ wp_set_object_terms( $product->get_id(), array( $orphan['term_id'], $independent['term_id'] ), 'product_cat' );
+
+ $resolved = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ add_filter( 'get_ancestors', $ancestors_filter, 10, 3 );
+
+ $unresolved = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ $this->assertSame( "{$independent_name} > {$orphan_name}", $resolved );
+ $this->assertSame( $resolved, $unresolved, 'A term whose ancestry cannot be resolved should rank exactly as a root term does.' );
+ } finally {
+ remove_filter( 'get_ancestors', $ancestors_filter, 10 );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $orphan['term_id'], 'product_cat' );
+ wp_delete_term( $independent['term_id'], 'product_cat' );
+ wp_delete_term( $missing_root['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list breadcrumb ordering terminates safely when ancestry is cyclic.
+ */
+ public function test_wc_get_product_category_list_breadcrumb_order_handles_cyclic_ancestry(): void {
+ $suffix = wp_unique_id();
+ $cyclic_root = wp_insert_term( 'Cyclic root ' . $suffix, 'product_cat' );
+ $cyclic_name = 'Cyclic child ' . $suffix;
+ $independent_name = 'Independent root ' . $suffix;
+ $cyclic_child = wp_insert_term( $cyclic_name, 'product_cat', array( 'parent' => $cyclic_root['term_id'] ) );
+ $independent = wp_insert_term( $independent_name, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $get_term_filter = static function ( $term, $taxonomy ) use ( $cyclic_root, $cyclic_child ) {
+ if ( 'product_cat' === $taxonomy && $term instanceof \WP_Term && (int) $cyclic_root['term_id'] === (int) $term->term_id ) {
+ /*
+ * Clone before mutating: get_term() hands back the cached instance, and mutating it in
+ * place would corrupt the term cache for every later test in the run.
+ */
+ $term = clone $term;
+ $term->parent = (int) $cyclic_child['term_id'];
+ }
+
+ return $term;
+ };
+
+ try {
+ /*
+ * The invisible root is left at order 0 on purpose: if it still steered the sort, the
+ * cyclic child would render first.
+ */
+ update_term_meta( $independent['term_id'], 'order', 1 );
+ update_term_meta( $cyclic_child['term_id'], 'order', 3 );
+ wp_set_object_terms( $product->get_id(), array( $cyclic_child['term_id'], $independent['term_id'] ), 'product_cat' );
+ add_filter( 'get_term', $get_term_filter, 10, 2 );
+
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'breadcrumb' ) );
+
+ $this->assertSame( "{$independent_name} > {$cyclic_name}", $actual );
+ } finally {
+ remove_filter( 'get_term', $get_term_filter, 10 );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $cyclic_child['term_id'], 'product_cat' );
+ wp_delete_term( $independent['term_id'], 'product_cat' );
+ wp_delete_term( $cyclic_root['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list can render assigned terms alphabetically by name.
+ */
+ public function test_wc_get_product_category_list_can_render_name_order(): void {
+ $suffix = wp_unique_id();
+ $second_name = 'Natural Category 2 ' . $suffix;
+ $tenth_name = 'Natural Category 10 ' . $suffix;
+ $tenth = wp_insert_term( $tenth_name, 'product_cat' );
+ $second = wp_insert_term( $second_name, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $term_links_filter = static function ( $links ) {
+ return array_map( static fn( $link ) => 'Filtered ' . $link, $links );
+ };
+
+ try {
+ update_term_meta( $second['term_id'], 'order', 2 );
+ update_term_meta( $tenth['term_id'], 'order', 1 );
+ wp_set_object_terms( $product->get_id(), array( $tenth['term_id'], $second['term_id'] ), 'product_cat' );
+ add_filter( 'term_links-product_cat', $term_links_filter );
+
+ $actual = wp_strip_all_tags( wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'name' ) );
+
+ $this->assertSame( "Filtered {$second_name} > Filtered {$tenth_name}", $actual );
+ } finally {
+ remove_filter( 'term_links-product_cat', $term_links_filter );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $second['term_id'], 'product_cat' );
+ wp_delete_term( $tenth['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list name ordering tolerates a term with no name.
+ */
+ public function test_wc_get_product_category_list_name_order_tolerates_missing_names(): void {
+ $suffix = wp_unique_id();
+ $named = wp_insert_term( 'Named category ' . $suffix, 'product_cat' );
+ $unnamed = wp_insert_term( 'Unnamed category ' . $suffix, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $unnamed_filter = static function ( $terms ) use ( $unnamed ) {
+ if ( ! is_array( $terms ) ) {
+ return $terms;
+ }
+
+ /*
+ * Clone before mutating: these objects come from the term cache, and blanking a name in
+ * place would leak into every later test in the run.
+ */
+ return array_map(
+ static function ( $term ) use ( $unnamed ) {
+ if ( $term instanceof \WP_Term && (int) $unnamed['term_id'] === (int) $term->term_id ) {
+ $term = clone $term;
+ $term->name = null;
+ }
+
+ return $term;
+ },
+ $terms
+ );
+ };
+
+ try {
+ wp_set_object_terms( $product->get_id(), array( $named['term_id'], $unnamed['term_id'] ), 'product_cat' );
+ add_filter( 'get_the_terms', $unnamed_filter, 99 );
+
+ $comparison_diagnostics = array();
+
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Capturing the diagnostic is the assertion; PHPUnit would otherwise convert it to an exception and hide which operand it came from.
+ set_error_handler(
+ static function ( $errno, $errstr ) use ( &$comparison_diagnostics ) {
+ if ( false !== stripos( $errstr, 'strnatcasecmp' ) ) {
+ $comparison_diagnostics[] = $errstr;
+ }
+
+ return true;
+ },
+ E_DEPRECATED | E_WARNING | E_NOTICE
+ );
+
+ try {
+ wc_get_product_category_list( $product->get_id(), ' > ', '', '', 'name' );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame(
+ array(),
+ $comparison_diagnostics,
+ 'Name ordering should not raise comparison diagnostics for a term with no name.'
+ );
+ } finally {
+ remove_filter( 'get_the_terms', $unnamed_filter, 99 );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $named['term_id'], 'product_cat' );
+ wp_delete_term( $unnamed['term_id'], 'product_cat' );
+ }
+ }
+
+ /**
+ * @testdox Product category list ordered modes validate values returned by the term-links filter.
+ */
+ public function test_wc_get_product_category_list_ordered_mode_validates_term_links_filter_result(): void {
+ $suffix = wp_unique_id();
+ $category = wp_insert_term( 'Filter result category ' . $suffix, 'product_cat' );
+ $product = WC_Helper_Product::create_simple_product();
+ $filter_error = new WP_Error( 'category-link-filter-error' );
+ $term_links_error_filter = static function () use ( $filter_error ) {
+ return $filter_error;
+ };
+ $term_links_invalid_filter = static function () {
+ return 'invalid-filter-result';
+ };
+
+ try {
+ wp_set_object_terms( $product->get_id(), array( $category['term_id'] ), 'product_cat' );
+ add_filter( 'term_links-product_cat', $term_links_error_filter );
+
+ $this->assertSame( $filter_error, wc_get_product_category_list( $product->get_id(), ', ', '', '', 'name' ) );
+
+ remove_filter( 'term_links-product_cat', $term_links_error_filter );
+ add_filter( 'term_links-product_cat', $term_links_invalid_filter );
+
+ $actual = wc_get_product_category_list( $product->get_id(), ', ', '', '', 'name' );
+
+ $this->assertFalse( $actual );
+ } finally {
+ remove_filter( 'term_links-product_cat', $term_links_error_filter );
+ remove_filter( 'term_links-product_cat', $term_links_invalid_filter );
+ WC_Helper_Product::delete_product( $product->get_id() );
+ wp_delete_term( $category['term_id'], 'product_cat' );
+ }
+ }
+
/**
* @testdox Product permalink should use deepest category, not the one with highest parent term ID.
*/
@@ -1369,11 +2206,13 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
}
foreach ( $terms as $key => $term ) {
if ( $term->term_id === $category1_term['term_id'] ) {
- unset( $terms[ $key ] ); // Intentionally don't re-index.
+ unset( $terms[ $key ] );
+ // Intentionally don't re-index.
break;
}
}
- return $terms; // Returns array with non-sequential keys.
+ return $terms;
+ // Returns array with non-sequential keys.
};
add_filter( 'get_the_terms', $filter_callback, 10, 3 );
diff --git a/plugins/woocommerce/tests/php/templates/single-product/SingleProductMetaTemplateTest.php b/plugins/woocommerce/tests/php/templates/single-product/SingleProductMetaTemplateTest.php
new file mode 100644
index 00000000000..a52e86e69d3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/templates/single-product/SingleProductMetaTemplateTest.php
@@ -0,0 +1,161 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Templates\SingleProduct;
+
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the single product meta template.
+ */
+class SingleProductMetaTemplateTest extends WC_Unit_Test_Case {
+
+ /**
+ * Whether the global product was set before the test.
+ *
+ * @var bool
+ */
+ private bool $had_previous_product;
+
+ /**
+ * The global product value from before the test.
+ *
+ * @var mixed
+ */
+ private $previous_product;
+
+ /**
+ * Product used by the test.
+ *
+ * @var \WC_Product
+ */
+ private $test_product;
+
+ /**
+ * Category names used by the test.
+ *
+ * @var array<string, string>
+ */
+ private array $category_names = array();
+
+ /**
+ * Category IDs used by the test.
+ *
+ * @var array<string, int>
+ */
+ private array $category_ids = array();
+
+ /**
+ * Set up the product category fixture.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->had_previous_product = array_key_exists( 'product', $GLOBALS );
+ $this->previous_product = $GLOBALS['product'] ?? null;
+
+ $suffix = wp_unique_id();
+ $this->category_names = array(
+ 'root' => 'Template Root ' . $suffix,
+ 'mid' => 'Template Mid ' . $suffix,
+ 'leaf' => 'Template Leaf ' . $suffix,
+ );
+ $root = wp_insert_term( $this->category_names['root'], 'product_cat' );
+ $mid = wp_insert_term( $this->category_names['mid'], 'product_cat', array( 'parent' => $root['term_id'] ) );
+ $leaf = wp_insert_term( $this->category_names['leaf'], 'product_cat', array( 'parent' => $mid['term_id'] ) );
+ $this->category_ids = array(
+ 'root' => $root['term_id'],
+ 'mid' => $mid['term_id'],
+ 'leaf' => $leaf['term_id'],
+ );
+ $this->test_product = WC_Helper_Product::create_simple_product();
+
+ $this->test_product->set_category_ids(
+ array( $this->category_ids['leaf'], $this->category_ids['root'], $this->category_ids['mid'] ),
+ );
+ $this->test_product->save();
+ $GLOBALS['product'] = $this->test_product;
+ }
+
+ /**
+ * Tear down the product category fixture.
+ */
+ public function tearDown(): void {
+ WC_Helper_Product::delete_product( $this->test_product->get_id() );
+
+ foreach ( array_reverse( $this->category_ids ) as $category_id ) {
+ wp_delete_term( $category_id, 'product_cat' );
+ }
+
+ if ( $this->had_previous_product ) {
+ $GLOBALS['product'] = $this->previous_product;
+ } else {
+ unset( $GLOBALS['product'] );
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Single product meta renders assigned categories in breadcrumb order.
+ */
+ public function test_single_product_meta_renders_categories_in_breadcrumb_order(): void {
+ $expected = implode(
+ ', ',
+ array( $this->category_names['root'], $this->category_names['mid'], $this->category_names['leaf'] )
+ );
+
+ $this->assertStringContainsString(
+ 'Categories: ' . $expected,
+ wp_strip_all_tags( wc_get_template_html( 'single-product/meta.php' ) ),
+ 'Single product meta should render product categories in root-to-leaf order.'
+ );
+ }
+
+ /**
+ * @testdox Single product meta honors the filtered category ordering mode.
+ */
+ public function test_single_product_meta_honors_filtered_category_order(): void {
+ $orderby_filter = static function () {
+ return 'name';
+ };
+ add_filter( 'woocommerce_product_meta_category_orderby', $orderby_filter );
+
+ try {
+ $expected = implode(
+ ', ',
+ array( $this->category_names['leaf'], $this->category_names['mid'], $this->category_names['root'] )
+ );
+
+ $this->assertStringContainsString(
+ 'Categories: ' . $expected,
+ wp_strip_all_tags( wc_get_template_html( 'single-product/meta.php' ) ),
+ 'Single product meta should honor the filtered product category ordering mode.'
+ );
+ } finally {
+ remove_filter( 'woocommerce_product_meta_category_orderby', $orderby_filter );
+ }
+ }
+
+ /**
+ * @testdox Single product meta omits category markup when category loading fails.
+ */
+ public function test_single_product_meta_omits_categories_when_term_loading_fails(): void {
+ $product_id = $this->test_product->get_id();
+ $terms_filter = static function ( $terms, $post_id, $taxonomy ) use ( $product_id ) {
+ return $product_id === $post_id && 'product_cat' === $taxonomy ? new \WP_Error( 'category-list-error' ) : $terms;
+ };
+ add_filter( 'get_the_terms', $terms_filter, 10, 3 );
+
+ try {
+ $this->assertStringNotContainsString(
+ 'class="posted_in"',
+ wc_get_template_html( 'single-product/meta.php' ),
+ 'Single product meta should suppress category markup when term loading fails.'
+ );
+ } finally {
+ remove_filter( 'get_the_terms', $terms_filter, 10 );
+ }
+ }
+}