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