Commit 9dd904abbc7 for woocommerce

commit 9dd904abbc729c4e0ebc3f762a3ab00dd79949fe
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Thu Aug 13 20:00:31 2026 +0300

    Prioritize product title matches in admin searches (#67508)

    * fix: preserve product search result order

    [Context]
    Products > All Products replaces native WordPress search with product IDs returned by the WooCommerce product data store.

    [Problem]
    The list table injected those IDs through post__in but left WP_Query on its default ordering, so newer content-only matches could appear ahead of the order returned by search_products().

    [Solution]
    Set orderby=post__in for product admin searches only when no explicit orderby was requested, preserving user-selected column sorting. Add list-table regression coverage for default search ordering and explicit title sorting.

    Refs #35607

    * fix: prioritize product title search matches

    The Products list delegates broad matching to the product data store, but its final query did not distinguish title matches from content-only results. Preserving the returned ID order was insufficient because that order is alphabetical and produced an unbounded FIELD() sort.\n\nRank title phrase matches only in the default Products list query, normalize quoted and OR search groups, and use ordinary title ordering within relevance groups. Explicit admin sorting and shared product-search callers remain unchanged.\n\nAdd final-query regressions for plain, quoted, OR, and explicit-title search behavior.\n\nRefs #35607

    * fix: prioritize title matches in product searches

    Product searches currently return broad WooCommerce matches in default date order, which can bury exact title matches behind products matched only through descriptions or identifiers.

    Keep search_products() as the inclusion source and add title-match relevance only to unsorted Products admin searches. Preserve explicit and hook-provided ordering and the existing date order within relevance buckets, while parsing quoted, OR-group, and multi-term searches consistently.

    This adds a bounded filesort for very broad match sets. Selective searches are effectively unchanged, and the 50,000-result one-term benchmark remained around 220 ms end to end.

    Refs #35607

    * fix: Harden admin product search relevance ranking

    [Context]
    Product admin searches prioritize title matches while retaining broad matches.

    [Problem]
    The ranker reused a partially initialized WP_Query tokenizer, exposing invalid state to core filters. Its stopwords could diverge from product inclusion. Core-supplied Draft or Pending ordering also disabled title priority, while an identical user-selected order remained indistinguishable.

    [Solution]
    Use private WooCommerce-aligned tokenization. Mark only implicit status-view ordering by checking the incoming request, then prefix relevance while retaining the original date clause.

    [Consequences]
    Explicit query sorting plus later posts_orderby filters remain authoritative. The existing unbounded filesort trade-off is unchanged.

    Refs #35607

    * chore: Add product search ranking changelog entry

    [Context]
    The product admin search ranking change is merchant-visible.

    [Problem]
    The changefile used the no-release-entry Comment field, so release tooling would omit it.

    [Solution]
    Replace the comment with a normal patch release-note body.

    Refs #35607

    * fix: validate the wp_search_stopwords filter output

    get_search_stopwords() returned the result of the wp_search_stopwords
    filter unvalidated. Any third-party callback can return a non-array,
    which get_valid_search_terms() then passes to in_array(), raising a
    TypeError on PHP 8 and fataling the Products list screen.

    Keep only string entries when the filtered value is an array, and fall
    back to the default stopword list otherwise. Non-string entries could
    never strictly match the lowercased string needle, so dropping them
    changes no matching behavior.

    Refs #35607

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * docs: document the product search term parsing regex

    The pattern in parse_search_terms() is copied from
    WP_Query::parse_search_terms() and is dense enough that its intent is
    not obvious from the code. Describe what it matches and note that the
    missing `u` modifier is deliberate for parity with WordPress core.

    Refs #35607

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * test: assert product search ranking through the generated ORDER BY

    test_product_search_ranking_does_not_invoke_core_search_filters()
    asserted that post_search_columns is never called, but core applies
    that filter only inside WP_Query::parse_search(), which is reached only
    when the s query var is non-empty — and query_filters() unsets s for
    every product search. The assertion held with the entire ranking
    feature disabled (verified by mutation), so the test could never fail.

    Replace it with assertions on what the ranking actually controls: the
    ORDER BY clause carries the CASE WHEN relevance prefix under a default
    search, omits it under an explicit sort, and falls back to ranking by
    the raw search group when only stopwords remain, mirroring
    search_products(). Each assertion was verified to fail with the
    ranking disabled.

    Refs #35607

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix: register product search ranking late on posts_orderby

    order_search_results() ran at priority 10 and prepended the CASE WHEN
    relevance prefix before most third-party posts_orderby callbacks. Any
    later callback that guards on an exact-string comparison against
    core's default clause — the same technique this class uses to detect
    the default — silently stopped firing on the products screen.

    Register at priority 9999 instead, so earlier callbacks observe the
    unmodified core clause. This also sharpens the feature's semantics:
    when an earlier callback changes the ordering, the exact-match guard
    in order_search_results() now skips ranking and defers to it, instead
    of ranking over a clause the callback never saw. Consumers of the
    posts_orderby_request and posts_clauses_request filters still observe
    the mutated clause; that is inherent to filtering the clause at all.

    The new test fails at priority 10 and passes at 9999.

    Refs #35607

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * docs: annotate order_search_results() with @since

    New public method on a class that extensions subclass; the version tag
    lets integrators gate on availability.

    Refs #35607

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * docs: explain the stopword ranking assertion pattern

    The regular expression asserting the stopword-only ranking clause was
    only partially explained. Spell out that it requires a CASE WHEN
    ranking clause and a post_title LIKE predicate containing the raw
    term, and why \S* stands in for the LIKE wildcards: at the
    posts_orderby stage they are dynamic wpdb placeholder-escape hashes
    rather than literal % characters.

    Refs #35607

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix: mirror search_products() OR grouping in the product search ranker

    The Products list ranking splits the search term into OR groups with
    preg_split( '/\s+or\s+/i', ... ), while the matcher it is supposed to
    mirror, WC_Product_Data_Store_CPT::search_products(), only splits when
    stristr( $term, ' or ' ) finds the keyword space-delimited.

    The two disagree on terms containing \x0B or \x0C: PCRE \s matches both,
    but _sanitize_text_fields() collapses only [\r\n\t ]+, so those bytes
    survive wc_clean() and reach the ranker intact. The matcher then treats
    "a\x0Bor\x0Bb" as one group, the ranker as two.

    Nothing observable came of it, because the matcher searched for the
    unsplit compound string, matched no products, and left post__in with no
    rows to order. Gating the split on the same condition keeps the stated
    parity between ranking and matching literally true rather than true only
    because the divergent case happens to return nothing.

    Refs #35607

    * refactor: extract the product search title-match clause builder

    order_search_results() did two unrelated jobs in one body: the first half
    decided whether the ranking applies at all, by re-deriving the default
    ordering and comparing the incoming clause against what core would have
    generated; the second half built the CASE expression. Reading either
    required scrolling past the other, and the guard's several early returns
    were interleaved with SQL assembly.

    Move the assembly into build_title_match_case_clause(), which returns an
    empty string when the term yields no title predicates. The guard now ends
    in a single expression that either prepends the clause or defers, and the
    builder can be read on its own.

    No behavior change.

    Refs #35607

    * test: pin product search fixture dates so ranking guards cannot pass on a tie

    create_search_products() and two tests that build fixtures inline created
    both products without calling set_date_created(), so each pair shared a
    post_date. Under the default ORDER BY post_date DESC that leaves the
    expected order to an unspecified tie-break rather than to the relevance
    clause the assertions exist to protect.

    The guards were not vacuous — deleting the ranking registration failed 18
    of 24 tests. But five of those failures came from the tie falling a
    particular way, not from the CASE clause: swapping only the creation order
    inside the helper dropped the count to 13, with the four
    ..._prioritizes_title_matches sets and
    ..._ranking_preserves_clause_for_other_posts_orderby_filters passing
    against ranking that was no longer registered. A MySQL upgrade, a plan
    change, or two saves straddling a second boundary could have done the same
    silently.

    Give each fixture an explicit date with the content-only match newer, so
    date ordering alone puts the wrong product first and only the ranking can
    restore the expected order. This is the shape of the reported bug and the
    convention already used by
    test_product_search_preserves_date_order_within_relevance_groups(). With
    the dates pinned, the same registration-removed mutation now fails 18 of 24
    with the creation order swapped as well.

    Refs #35607

    * fix: apply product search ranking on posts_clauses instead of posts_orderby

    Core applies posts_clauses after every posts_orderby priority
    (WP_Query::get_posts(), class-wp-query.php:2967 and :3020). Registering the
    ranking on posts_orderby at 9999 therefore left the relevance clause visible
    to two groups of third-party callbacks: posts_orderby callbacks above 9999,
    and posts_clauses callbacks at any priority, since no priority there can
    precede the prefix. A plugin in either group that matches on core's exact
    default ORDER BY string to decide whether to act silently stopped acting on
    product searches.

    Move the registration to posts_clauses at 9999. Every posts_orderby callback
    now observes the clause core generated, as does every posts_clauses callback
    below 9999, and ordering set by any of them makes the ranking defer instead.

    Reading the clause later also widens what the ranking stands down for: an
    earlier posts_clauses callback that appends a tiebreak, not just one that
    replaces the clause, now takes precedence. That is the intended contract —
    third-party ordering wins — and is covered by a new test.

    The emitted SQL is unchanged. Captured through posts_request at both hook
    sites, the final statement is byte-for-byte identical, so the query plan and
    the accepted 50k benchmark shape still apply.

    Refs #35607

    * fix: rank product searches when a filter only appends an ORDER BY tiebreak

    The ranking guard required the incoming clause to be byte-identical to the one
    core generated. Reading that clause on posts_clauses means it has already passed
    through every posts_orderby priority and every earlier posts_clauses callback,
    so byte-identity conflated two very different third-party behaviours: replacing
    the ordering, and appending a tiebreak to it.

    Appending is the common shape — WooCommerce itself reaches for it, adding ' ID'
    to orderby "for consistency with pagination" in the REST controller. A plugin
    doing that on posts_clauses left core's ordering in charge of the primary sort,
    yet the guard treated the clause as foreign and skipped ranking entirely. On
    such a store the Products admin search silently reverted to date order and this
    fix did not apply, with no error to indicate it.

    Compare the same appending callback across both placements. Reading the clause
    on posts_orderby produced CASE WHEN ..., post_date DESC, ID ASC — relevance led
    and the tiebreak survived. Reading it on posts_clauses produced post_date DESC,
    ID ASC, dropping two of the four title matches off the first page.

    Test whether the clause still leads with core's ordering instead. A callback
    that appended keeps its tiebreak after the relevance clause; one that prepended
    or replaced has taken over the primary sort and is still deferred to, since only
    those change which rows come first. Only a comma may follow the core clause, so
    a longer token starting the same way is not mistaken for it.

    Refs #35607

    * docs: correct the deferral comments on the product search ranker

    Both the registration comment and the order_search_results() docblock still
    described the guard as it behaved before the leads-with change: any ordering an
    earlier callback set was said to make the ranker stand down. That stopped being
    true when the guard moved from byte-identity to testing whether the clause still
    leads with core's ordering — a callback that only appends a tiebreak now keeps
    ranking, and its tiebreak survives after the relevance clause.

    State the distinction the code actually draws: taking over the primary sort by
    prepending or replacing is deferred to, appending is not.

    Refs #35607

    * docs: tighten the product search ranking comments

    Several comments had accumulated restatement as the ranker changed hook site and
    guard. The registration site, the method docblock and orderby_leads_with() each
    carried a version of the deferral rule, so a reader met it three times and had
    to check whether the three agreed. Others explained what the code already says:
    the single-character rule in get_valid_search_terms() restated its own
    condition, and a test comment described the mechanism its assertion asserts.

    Keep one home per fact. The deferral rule now lives with the predicate that
    implements it; the registration site says which hook and why; the docblock says
    why the clause is read late. get_valid_search_terms() states once that it
    mirrors WP_Query::get_search_terms(), which covers the rules it inherits and is
    more useful than the inherited line-by-line notes.

    Comments that carry something the code cannot are kept and left explicit: the
    one-space difference between the two trim() calls, the omitted `u` modifier in
    the mirrored regex, why \s+or\s+ alone would not match search_products()
    grouping, and why the test fixtures carry explicit dates.

    Refs #35607

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-35607-product-search-order b/plugins/woocommerce/changelog/fix-35607-product-search-order
new file mode 100644
index 00000000000..b02381a73df
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-35607-product-search-order
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prioritize product title matches in default admin searches while preserving broad matches and explicit sorting.
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
index c3f30b92403..d3c98a7df7d 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
@@ -59,6 +59,10 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 		add_filter( 'views_edit-product', array( $this, 'product_views' ) );
 		add_filter( 'get_search_query', array( $this, 'search_label' ) );
 		add_filter( 'posts_clauses', array( $this, 'posts_clauses' ), 10, 2 );
+		// On posts_clauses, which core applies after every posts_orderby priority, so no posts_orderby
+		// callback sees the relevance clause. The late priority gives earlier posts_clauses callbacks
+		// that same unmodified view.
+		add_filter( 'posts_clauses', array( $this, 'order_search_results' ), 9999, 2 );

 		// Use hooks to prime various caches and improve products page performance.
 		add_action( 'load-edit.php', array( $this, 'prime_status_counts_cache' ) );
@@ -633,10 +637,16 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {

 		// Search using CRUD.
 		if ( ! empty( $query_vars['s'] ) ) {
+			$search_term                  = wc_clean( wp_unslash( $query_vars['s'] ) );
 			$data_store                   = WC_Data_Store::load( 'product' );
-			$ids                          = $data_store->search_products( wc_clean( wp_unslash( $query_vars['s'] ) ), '', true, true );
+			$ids                          = $data_store->search_products( $search_term, '', true, true );
 			$query_vars['post__in']       = array_merge( $ids, array( 0 ) );
 			$query_vars['product_search'] = true;
+			$default_search_order         = $this->get_default_search_order( $query_vars );
+			if ( null !== $default_search_order ) {
+				$query_vars['product_search_term']          = $search_term;
+				$query_vars['product_search_default_order'] = $default_search_order;
+			}
 			unset( $query_vars['s'] );
 		}

@@ -655,6 +665,239 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 		return $args;
 	}

+	/**
+	 * Prioritize title matches in unsorted product searches.
+	 *
+	 * Runs on posts_clauses rather than posts_orderby so that every posts_orderby callback, at any
+	 * priority, still observes the ORDER BY clause core generated. Reading it that late means it may
+	 * already carry third-party ordering, so orderby_leads_with() decides whether ranking still leads.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @param array    $clauses Array of SELECT statement pieces (from, where, orderby, etc).
+	 * @param WP_Query $query   WP_Query instance.
+	 * @return array
+	 */
+	public function order_search_results( $clauses, $query ) {
+		// Another callback produced this array, so confirm the piece being read is still a clause.
+		if ( ! is_array( $clauses ) || ! isset( $clauses['orderby'] ) || ! is_string( $clauses['orderby'] ) ) {
+			return $clauses;
+		}
+
+		$orderby              = $clauses['orderby'];
+		$search_term          = $query->get( 'product_search_term' );
+		$default_search_order = $query->get( 'product_search_default_order' );
+		if ( ! $query->get( 'product_search' ) || ! is_string( $search_term ) || '' === $search_term ) {
+			return $clauses;
+		}
+
+		global $wpdb;
+		if ( 'date' === $default_search_order ) {
+			if ( $query->get( 'orderby' ) || ! $this->orderby_leads_with( $orderby, "{$wpdb->posts}.post_date DESC" ) ) {
+				return $clauses;
+			}
+		} elseif ( 'modified' === $default_search_order ) {
+			$post_status = $query->get( 'post_status' );
+			$order       = $query->get( 'order' );
+			if (
+				'modified' !== $query->get( 'orderby' )
+				|| ! is_string( $post_status )
+				|| ! is_string( $order )
+				|| ( ! ( 'draft' === $post_status && 'DESC' === $order ) && ! ( 'pending' === $post_status && 'ASC' === $order ) )
+				|| ! $this->orderby_leads_with( $orderby, "{$wpdb->posts}.post_modified {$order}" )
+			) {
+				return $clauses;
+			}
+		} else {
+			return $clauses;
+		}
+
+		$case_clause = $this->build_title_match_case_clause( $search_term );
+		if ( '' === $case_clause ) {
+			return $clauses;
+		}
+
+		$clauses['orderby'] = $case_clause . ', ' . $orderby;
+
+		return $clauses;
+	}
+
+	/**
+	 * Determine whether a clause still leads with the ordering core generated for this view.
+	 *
+	 * A third party that appends a tiebreak leaves core's ordering in charge of the primary sort, so
+	 * ranking can still lead. One that prepends or replaces has taken the primary sort over, and
+	 * ranking defers to it. Only a comma may follow the core clause, so a longer token that merely
+	 * starts the same way is not mistaken for it.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @param string $orderby      Clause observed on the query.
+	 * @param string $core_orderby Clause core generated for this view.
+	 * @return bool
+	 */
+	private function orderby_leads_with( $orderby, $core_orderby ) {
+		return $orderby === $core_orderby || 0 === strpos( $orderby, $core_orderby . ',' );
+	}
+
+	/**
+	 * Build the relevance expression that ranks title matches ahead of the remaining search results.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @param string $search_term Search term the product matcher already ran against.
+	 * @return string CASE expression, or an empty string when the term yields no title predicates.
+	 */
+	private function build_title_match_case_clause( $search_term ) {
+		global $wpdb;
+
+		// Group exactly as search_products() does, so ranking and matching agree on what an OR group is.
+		// \s+or\s+ alone would not: \s also matches \x0B and \x0C, which wc_clean() leaves in place.
+		$search_groups = stristr( $search_term, ' or ' ) ? preg_split( '/\s+or\s+/i', $search_term ) : array( $search_term );
+		if ( ! $search_groups ) {
+			return '';
+		}
+
+		$title_match_groups = array();
+		foreach ( $search_groups as $search_group ) {
+			$title_match_queries = array();
+			foreach ( $this->parse_search_terms( $search_group ) as $title_search_term ) {
+				$title_match_queries[] = $wpdb->prepare(
+					"{$wpdb->posts}.post_title LIKE %s",
+					'%' . $wpdb->esc_like( $title_search_term ) . '%'
+				);
+			}
+
+			if ( $title_match_queries ) {
+				$title_match_groups[] = '(' . implode( ' AND ', $title_match_queries ) . ')';
+			}
+		}
+
+		if ( ! $title_match_groups ) {
+			return '';
+		}
+
+		return 'CASE WHEN (' . implode( ' OR ', $title_match_groups ) . ') THEN 0 ELSE 1 END';
+	}
+
+	/**
+	 * Get the default ordering mode for a product search.
+	 *
+	 * @param array $query_vars Query variables.
+	 * @return string|null
+	 */
+	private function get_default_search_order( $query_vars ) {
+		$orderby = $query_vars['orderby'] ?? '';
+		if ( ! is_string( $orderby ) ) {
+			return null;
+		}
+
+		if ( '' === $orderby ) {
+			return 'date';
+		}
+
+		// Draft and Pending views receive modified ordering from wp_edit_posts_query() only when no orderby was requested.
+		if ( isset( $_GET['orderby'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only presence check distinguishes explicit sorting from the core default.
+			return null;
+		}
+
+		$post_status = $query_vars['post_status'] ?? '';
+		$order       = $query_vars['order'] ?? '';
+		if ( 'modified' !== strtolower( $orderby ) || ! is_string( $post_status ) || ! is_string( $order ) ) {
+			return null;
+		}
+
+		$order = $order ? strtoupper( $order ) : 'DESC';
+		if ( ( 'draft' === $post_status && 'DESC' === $order ) || ( 'pending' === $post_status && 'ASC' === $order ) ) {
+			return 'modified';
+		}
+
+		return null;
+	}
+
+	/**
+	 * Parse a product search group using WooCommerce search-term rules.
+	 *
+	 * @param string $search_group Search group without OR separators.
+	 * @return string[]
+	 */
+	private function parse_search_terms( $search_group ) {
+		// Mirrors WP_Query::parse_search_terms(): each match is either a double-quoted phrase (closed by
+		// a quote or the end of the string) or a run of characters delimited by tab, space, double quote,
+		// comma, or plus. The `u` modifier is intentionally omitted for parity with WordPress core.
+		if ( ! preg_match_all( '/".*?("|$)|((?<=[\t ",+])|^)[^\t ",+]+/', $search_group, $matches ) ) {
+			return array( $search_group );
+		}
+
+		$search_terms = $this->get_valid_search_terms( $matches[0] );
+		$count        = count( $search_terms );
+
+		return 9 < $count || 0 === $count ? array( $search_group ) : $search_terms;
+	}
+
+	/**
+	 * Remove unsuitable product search terms.
+	 *
+	 * Mirrors WP_Query::get_search_terms(), including its stopword and single-character rules.
+	 *
+	 * @param string[] $terms Search terms.
+	 * @return string[]
+	 */
+	private function get_valid_search_terms( $terms ) {
+		$valid_terms = array();
+		$stopwords   = $this->get_search_stopwords();
+
+		foreach ( $terms as $term ) {
+			// Keep before/after spaces when term is for exact match, otherwise trim quotes and spaces.
+			if ( preg_match( '/^".+"$/', $term ) ) {
+				$term = trim( $term, "\"'" );
+			} else {
+				$term = trim( $term, "\"' " );
+			}
+
+			if ( empty( $term ) || ( 1 === strlen( $term ) && preg_match( '/^[a-z\-]$/i', $term ) ) ) {
+				continue;
+			}
+
+			if ( in_array( wc_strtolower( $term ), $stopwords, true ) ) {
+				continue;
+			}
+
+			$valid_terms[] = $term;
+		}
+
+		return $valid_terms;
+	}
+
+	/**
+	 * Retrieve stopwords used when parsing product search terms.
+	 *
+	 * @return string[]
+	 */
+	private function get_search_stopwords() {
+		// Translators: This is a comma-separated list of very common words that should be excluded from a search, like a, an, and the. These are usually called "stopwords". You should not simply translate these individual words into your language. Instead, look for and provide commonly accepted stopwords in your language.
+		$stopwords = array_map(
+			'wc_strtolower',
+			array_map(
+				'trim',
+				explode(
+					',',
+					_x(
+						'about,an,are,as,at,be,by,com,for,from,how,in,is,it,of,on,or,that,the,this,to,was,what,when,where,who,will,with,www',
+						'Comma-separated list of search stopwords in your language',
+						'woocommerce'
+					)
+				)
+			)
+		);
+
+		/** This filter is documented in wp-includes/class-wp-query.php. */
+		$filtered_stopwords = apply_filters( 'wp_search_stopwords', $stopwords ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment -- The hook is documented by WordPress.
+
+		// A callback can return anything; keep only strings so the strict in_array() below stays safe.
+		return is_array( $filtered_stopwords ) ? array_filter( $filtered_stopwords, 'is_string' ) : $stopwords;
+	}
+
 	/**
 	 * Remove ordering queries.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php
index a5a5b801bdd..93e047bc5ba 100644
--- a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-products-test.php
@@ -18,6 +18,13 @@ require_once WC_ABSPATH . 'includes/admin/list-tables/class-wc-admin-list-table-
  */
 class WC_Admin_List_Table_Products_Test extends WC_Unit_Test_Case {

+	/**
+	 * The system under test.
+	 *
+	 * @var WC_Admin_List_Table_Products
+	 */
+	private $sut;
+
 	/**
 	 * Original global stock management option.
 	 *
@@ -25,12 +32,22 @@ class WC_Admin_List_Table_Products_Test extends WC_Unit_Test_Case {
 	 */
 	private $original_manage_stock;

+	/**
+	 * Original post type for the current admin screen.
+	 *
+	 * @var string|null
+	 */
+	private $original_typenow;
+
 	/**
 	 * Set up.
 	 */
 	public function setUp(): void {
 		parent::setUp();
 		$this->original_manage_stock = get_option( 'woocommerce_manage_stock' );
+		$this->original_typenow      = $GLOBALS['typenow'] ?? null;
+		$GLOBALS['typenow']          = 'product'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$this->sut                   = new WC_Admin_List_Table_Products();
 	}

 	/**
@@ -43,10 +60,591 @@ class WC_Admin_List_Table_Products_Test extends WC_Unit_Test_Case {
 			update_option( 'woocommerce_manage_stock', $this->original_manage_stock );
 		}

+		if ( null === $this->original_typenow ) {
+			unset( $GLOBALS['typenow'] );
+		} else {
+			$GLOBALS['typenow'] = $this->original_typenow; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		}
+
 		parent::tearDown();
 	}

 	/**
+	 * @testdox Product searches prioritize title matches for supported search syntax.
+	 * @dataProvider search_term_provider
+	 *
+	 * @param string $search_format Search term format.
+	 */
+	public function test_product_search_prioritizes_title_matches( string $search_format ): void {
+		list( $title_match, $content_match, $search_phrase ) = $this->create_search_products();
+		$results = $this->get_search_results( sprintf( $search_format, $search_phrase ) );
+
+		$this->assertSame(
+			array( $title_match->get_id(), $content_match->get_id() ),
+			$results,
+			'Products with a title match should be listed before products that only match in their content.'
+		);
+	}
+
+	/**
+	 * @testdox Product searches prioritize titles that match parsed search terms.
+	 * @dataProvider parsed_search_term_provider
+	 *
+	 * @param string $title_format  Product title format.
+	 * @param string $search_format Search term format.
+	 */
+	public function test_product_search_prioritizes_titles_matching_parsed_terms( string $title_format, string $search_format ): void {
+		$token        = wp_generate_password( 8, false );
+		$product_name = sprintf( $title_format, $token );
+		$search_term  = sprintf( $search_format, $token );
+
+		$title_match = WC_Helper_Product::create_simple_product();
+		$title_match->set_name( $product_name );
+		$title_match->set_date_created( '2024-01-01 00:00:00' );
+		$title_match->save();
+
+		$content_match = WC_Helper_Product::create_simple_product();
+		$content_match->set_name( 'Archive Lamp Notes ' . wp_generate_password( 8, false ) );
+		$content_match->set_description( $product_name );
+		$content_match->set_date_created( '2024-01-02 00:00:00' );
+		$content_match->save();
+
+		$results = $this->get_search_results( $search_term );
+
+		$this->assertSame(
+			array( $title_match->get_id(), $content_match->get_id() ),
+			$results,
+			'Products whose titles match every parsed search term should be listed before content-only matches.'
+		);
+	}
+
+	/**
+	 * Parsed search terms for title-priority coverage.
+	 *
+	 * @return array<string, array<string>>
+	 */
+	public function parsed_search_term_provider(): array {
+		return array(
+			'multiple quoted phrases' => array( 'Night Light %1$s Warm Glow', '"Night Light %1$s" "Warm Glow"' ),
+			'single-quoted terms'     => array( 'Night Light %1$s', "'Night Light %1\$s'" ),
+			'comma-separated terms'   => array( 'Night Light %1$s', 'Night,Light,%1$s' ),
+			'plus-separated terms'    => array( 'Night Light %1$s', 'Night+Light+%1$s' ),
+			'ignored stopword'        => array( 'Night Light %1$s', 'Night the Light %1$s' ),
+			'title punctuation'       => array( 'Night-Light %1$s', 'Night Light %1$s' ),
+		);
+	}
+
+	/**
+	 * @testdox Product title ranking prepends a relevance clause only for default search ordering.
+	 */
+	public function test_product_search_ranking_controls_the_orderby_clause(): void {
+		list( , , $search_phrase ) = $this->create_search_products();
+
+		$captured_orderby = null;
+		$capture_orderby  = static function ( $clauses ) use ( &$captured_orderby ) {
+			$captured_orderby = is_array( $clauses ) && isset( $clauses['orderby'] ) ? $clauses['orderby'] : null;
+			return $clauses;
+		};
+		add_filter( 'posts_clauses', $capture_orderby, PHP_INT_MAX );
+
+		try {
+			$this->get_search_results( $search_phrase );
+			$this->assertStringContainsString( 'CASE WHEN', (string) $captured_orderby, 'A default-ordered search should rank title matches first.' );
+			$this->assertStringContainsString( 'post_title LIKE', (string) $captured_orderby, 'The relevance clause should match against product titles.' );
+
+			$captured_orderby = null;
+			$this->get_search_results(
+				$search_phrase,
+				array(
+					'orderby' => 'title',
+					'order'   => 'ASC',
+				)
+			);
+			$this->assertStringNotContainsString( 'CASE WHEN', (string) $captured_orderby, 'An explicitly sorted search should keep the requested ordering untouched.' );
+
+			// A stopword-only term falls back to ranking by the whole group, mirroring search_products().
+			// \S* absorbs the LIKE wildcards around the term: at the posts_clauses stage they are still
+			// dynamic wpdb placeholder-escape hashes, not %.
+			$captured_orderby = null;
+			$this->get_search_results( 'the' );
+			$this->assertMatchesRegularExpression( "/CASE WHEN.+post_title LIKE '\\S*the\\S*'/", (string) $captured_orderby, 'A stopword-only search should rank by the raw search group.' );
+		} finally {
+			remove_filter( 'posts_clauses', $capture_orderby, PHP_INT_MAX );
+		}
+	}
+
+	/**
+	 * @testdox Product title ranking uses the same translated stopwords as product inclusion.
+	 */
+	public function test_product_search_uses_woocommerce_stopwords_for_title_ranking(): void {
+		$token       = wp_generate_password( 8, false );
+		$search_term = 'Night the ' . $token;
+
+		$translate_stopwords = static function ( $translation, $text, $context, $domain ) {
+			$source = 'about,an,are,as,at,be,by,com,for,from,how,in,is,it,of,on,or,that,the,this,to,was,what,when,where,who,will,with,www';
+			if ( $source !== $text || 'Comma-separated list of search stopwords in your language' !== $context ) {
+				return $translation;
+			}
+
+			return 'woocommerce' === $domain ? 'the' : 'about';
+		};
+		add_filter( 'gettext_with_context', $translate_stopwords, 10, 4 );
+
+		try {
+			$title_match = WC_Helper_Product::create_simple_product();
+			$title_match->set_name( 'Night ' . $token );
+			$title_match->set_date_created( '2024-01-01 00:00:00' );
+			$title_match->save();
+
+			$content_match = WC_Helper_Product::create_simple_product();
+			$content_match->set_name( 'Archive translated notes' );
+			$content_match->set_description( $search_term );
+			$content_match->set_date_created( '2024-01-02 00:00:00' );
+			$content_match->save();
+
+			$results = $this->get_search_results( $search_term );
+		} finally {
+			remove_filter( 'gettext_with_context', $translate_stopwords, 10 );
+		}
+
+		$this->assertSame(
+			array( $title_match->get_id(), $content_match->get_id() ),
+			$results,
+			'Title ranking should discard the same translated stopwords as the broad product search.'
+		);
+	}
+
+	/**
+	 * @testdox Product searches retain broad content, SKU, GTIN, and variation matches after title matches.
+	 */
+	public function test_product_search_retains_broad_matches_after_title_matches(): void {
+		$search_term = (string) wp_rand( 10000000, 99999999 );
+
+		$title_match = WC_Helper_Product::create_simple_product();
+		$title_match->set_name( 'Night Light ' . $search_term );
+		$title_match->save();
+
+		$content_match = WC_Helper_Product::create_simple_product();
+		$content_match->set_name( 'Archive Content Match' );
+		$content_match->set_description( $search_term );
+		$content_match->save();
+
+		$gtin_match = WC_Helper_Product::create_simple_product();
+		$gtin_match->set_name( 'Catalog GTIN Match' );
+		$gtin_match->set_global_unique_id( '111' . $search_term . '11' );
+		$gtin_match->save();
+
+		$sku_match = WC_Helper_Product::create_simple_product();
+		$sku_match->set_name( 'Catalog SKU Match' );
+		$sku_match->set_sku( 'sku-' . $search_term );
+		$sku_match->save();
+
+		$variable_match = new WC_Product_Variable();
+		$variable_match->set_name( 'Variation Match' );
+		$variable_match->save();
+
+		$variation = new WC_Product_Variation();
+		$variation->set_parent_id( $variable_match->get_id() );
+		$variation->set_regular_price( '10' );
+		$variation->set_sku( 'variation-' . $search_term );
+		$variation->save();
+
+		$results = $this->get_search_results( $search_term );
+
+		$this->assertSame( $title_match->get_id(), $results[0], 'The title match should be ranked first.' );
+
+		$expected_broad_matches = array(
+			$content_match->get_id(),
+			$gtin_match->get_id(),
+			$sku_match->get_id(),
+			$variable_match->get_id(),
+		);
+		$actual_broad_matches   = array_slice( $results, 1 );
+		sort( $expected_broad_matches );
+		sort( $actual_broad_matches );
+
+		$this->assertSame( $expected_broad_matches, $actual_broad_matches, 'Broad product matches should remain available after title-priority ranking.' );
+	}
+
+	/**
+	 * @testdox Product searches preserve default date ordering within relevance groups.
+	 */
+	public function test_product_search_preserves_date_order_within_relevance_groups(): void {
+		$search_term = 'Lantern ' . wp_generate_password( 8, false );
+
+		$title_older = WC_Helper_Product::create_simple_product();
+		$title_older->set_name( 'Alpha ' . $search_term );
+		$title_older->set_date_created( '2024-01-01 00:00:00' );
+		$title_older->save();
+
+		$title_newer = WC_Helper_Product::create_simple_product();
+		$title_newer->set_name( 'Zulu ' . $search_term );
+		$title_newer->set_date_created( '2024-01-04 00:00:00' );
+		$title_newer->save();
+
+		$content_older = WC_Helper_Product::create_simple_product();
+		$content_older->set_name( 'Bravo catalog notes' );
+		$content_older->set_description( $search_term );
+		$content_older->set_date_created( '2024-01-02 00:00:00' );
+		$content_older->save();
+
+		$content_newer = WC_Helper_Product::create_simple_product();
+		$content_newer->set_name( 'Yankee catalog notes' );
+		$content_newer->set_description( $search_term );
+		$content_newer->set_date_created( '2024-01-03 00:00:00' );
+		$content_newer->save();
+
+		$this->assertSame(
+			array(
+				$title_newer->get_id(),
+				$title_older->get_id(),
+				$content_newer->get_id(),
+				$content_older->get_id(),
+			),
+			$this->get_search_results( $search_term ),
+			'Title relevance should add buckets without replacing the existing date order inside each bucket.'
+		);
+	}
+
+	/**
+	 * @testdox Product searches distinguish core status-view ordering from explicit modified-date sorting.
+	 * @dataProvider core_status_ordering_provider
+	 *
+	 * @param string $post_status     Product status.
+	 * @param string $order           Status-view order.
+	 * @param bool   $explicit_order  Whether the order came from an explicit request.
+	 */
+	public function test_product_search_handles_status_view_ordering( string $post_status, string $order, bool $explicit_order ): void {
+		$search_term = 'Lantern ' . wp_generate_password( 8, false );
+
+		$create_product = static function ( string $name, string $description, string $modified ) use ( $post_status ): WC_Product {
+			global $wpdb;
+
+			$product = WC_Helper_Product::create_simple_product();
+			$product->set_name( $name );
+			$product->set_description( $description );
+			$product->set_status( $post_status );
+			$product->save();
+
+			// wp_insert_post() overwrites modified timestamps with the current time.
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+			$wpdb->update(
+				$wpdb->posts,
+				array(
+					'post_modified'     => $modified,
+					'post_modified_gmt' => $modified,
+				),
+				array( 'ID' => $product->get_id() )
+			);
+			clean_post_cache( $product->get_id() );
+
+			return $product;
+		};
+
+		$title_older   = $create_product( 'Alpha ' . $search_term, '', '2024-01-01 00:00:00' );
+		$content_older = $create_product( 'Bravo catalog notes', $search_term, '2024-01-02 00:00:00' );
+		$content_newer = $create_product( 'Yankee catalog notes', $search_term, '2024-01-03 00:00:00' );
+		$title_newer   = $create_product( 'Zulu ' . $search_term, '', '2024-01-04 00:00:00' );
+
+		if ( $explicit_order ) {
+			$expected = 'ASC' === $order
+				? array( $title_older->get_id(), $content_older->get_id(), $content_newer->get_id(), $title_newer->get_id() )
+				: array( $title_newer->get_id(), $content_newer->get_id(), $content_older->get_id(), $title_older->get_id() );
+		} else {
+			$expected = 'ASC' === $order
+				? array( $title_older->get_id(), $title_newer->get_id(), $content_older->get_id(), $content_newer->get_id() )
+				: array( $title_newer->get_id(), $title_older->get_id(), $content_newer->get_id(), $content_older->get_id() );
+		}
+
+		// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Preserve the exact test-global state around this request simulation.
+		$had_orderby      = array_key_exists( 'orderby', $_GET );
+		$previous_orderby = $had_orderby ? $_GET['orderby'] : null;
+		// phpcs:enable
+		if ( $explicit_order ) {
+			$_GET['orderby'] = 'modified';
+		} else {
+			unset( $_GET['orderby'] );
+		}
+
+		try {
+			$results = $this->get_search_results(
+				$search_term,
+				array(
+					'post_status' => $post_status,
+					'orderby'     => 'modified',
+					'order'       => $order,
+				)
+			);
+		} finally {
+			if ( $had_orderby ) {
+				$_GET['orderby'] = $previous_orderby;
+			} else {
+				unset( $_GET['orderby'] );
+			}
+		}
+
+		$this->assertSame(
+			$expected,
+			$results,
+			$explicit_order
+				? 'Explicit modified-date sorting should take precedence over search relevance.'
+				: 'Core status-view ordering should remain the tiebreak within title and content relevance groups.'
+		);
+	}
+
+	/**
+	 * Core status-view ordering for title-priority coverage.
+	 *
+	 * @return array<string, array{string, string, bool}>
+	 */
+	public function core_status_ordering_provider(): array {
+		return array(
+			'drafts use core modified descending'       => array( 'draft', 'DESC', false ),
+			'pending uses core modified ascending'      => array( 'pending', 'ASC', false ),
+			'drafts keep explicit modified descending'  => array( 'draft', 'DESC', true ),
+			'pending keeps explicit modified ascending' => array( 'pending', 'ASC', true ),
+		);
+	}
+
+	/**
+	 * @testdox Product searches prioritize titles containing a literal backslash.
+	 */
+	public function test_product_search_prioritizes_title_matches_with_literal_backslashes(): void {
+		$search_term = 'Back\\Slash ' . wp_generate_password( 8, false );
+
+		$title_match = WC_Helper_Product::create_simple_product();
+		$title_match->set_name( $search_term );
+		$title_match->set_date_created( '2024-01-01 00:00:00' );
+		$title_match->save();
+
+		$content_match = WC_Helper_Product::create_simple_product();
+		$content_match->set_name( 'Archive backslash notes' );
+		$content_match->set_description( $search_term );
+		$content_match->set_date_created( '2024-01-02 00:00:00' );
+		$content_match->save();
+
+		// Preserve the literal backslash as WordPress expects slashed post data.
+		wp_update_post(
+			wp_slash(
+				array(
+					'ID'         => $title_match->get_id(),
+					'post_title' => $search_term,
+				)
+			)
+		);
+		wp_update_post(
+			wp_slash(
+				array(
+					'ID'           => $content_match->get_id(),
+					'post_content' => $search_term,
+				)
+			)
+		);
+
+		$this->assertSame(
+			array( $title_match->get_id(), $content_match->get_id() ),
+			$this->get_search_results( wp_slash( $search_term ) ),
+			'A request-unslashed literal backslash should survive the ranking parser.'
+		);
+	}
+
+	/**
+	 * @testdox Product searches keep explicit admin sorting choices.
+	 */
+	public function test_product_search_keeps_explicit_orderby(): void {
+		list( $title_match, $content_match, $search_phrase ) = $this->create_search_products();
+		$results = $this->get_search_results(
+			$search_phrase,
+			array(
+				'orderby' => 'title',
+				'order'   => 'ASC',
+			)
+		);
+
+		$this->assertSame(
+			array( $content_match->get_id(), $title_match->get_id() ),
+			$results,
+			'Explicit title sorting should take precedence over search relevance.'
+		);
+	}
+
+	/**
+	 * @testdox Product searches keep orderby values added during pre_get_posts.
+	 */
+	public function test_product_search_keeps_late_explicit_orderby(): void {
+		list( $title_match, $content_match, $search_phrase ) = $this->create_search_products();
+
+		$set_title_order = static function ( WP_Query $query ): void {
+			if ( $query->get( 'product_search' ) ) {
+				$query->set( 'orderby', 'title' );
+				$query->set( 'order', 'ASC' );
+			}
+		};
+		add_action( 'pre_get_posts', $set_title_order );
+
+		try {
+			$results = $this->get_search_results( $search_phrase );
+		} finally {
+			remove_action( 'pre_get_posts', $set_title_order );
+		}
+
+		$this->assertSame(
+			array( $content_match->get_id(), $title_match->get_id() ),
+			$results,
+			'An explicit orderby added after request parsing should take precedence over search relevance.'
+		);
+	}
+
+	/**
+	 * @testdox Product searches honor later posts_orderby filters.
+	 */
+	public function test_product_search_keeps_later_posts_orderby_filter(): void {
+		list( $title_match, $content_match, $search_phrase ) = $this->create_search_products();
+
+		$set_title_order = static function ( $orderby, WP_Query $query ) {
+			global $wpdb;
+
+			return $query->get( 'product_search' ) ? "{$wpdb->posts}.post_title ASC" : $orderby;
+		};
+		add_filter( 'posts_orderby', $set_title_order, 20, 2 );
+
+		try {
+			$results = $this->get_search_results( $search_phrase );
+		} finally {
+			remove_filter( 'posts_orderby', $set_title_order, 20 );
+		}
+
+		$this->assertSame(
+			array( $content_match->get_id(), $title_match->get_id() ),
+			$results,
+			'A later posts_orderby filter should take precedence over search relevance.'
+		);
+	}
+
+	/**
+	 * @testdox Product searches defer to a primary sort an earlier posts_clauses filter set.
+	 */
+	public function test_product_search_defers_to_earlier_posts_clauses_ordering(): void {
+		list( $title_match, $content_match, $search_phrase ) = $this->create_search_products();
+
+		// Prepending takes over the primary sort, so ranking has to stand down entirely.
+		$lead_with_title = static function ( $clauses, $query ) {
+			global $wpdb;
+
+			if ( is_array( $clauses ) && isset( $clauses['orderby'] ) && $query->get( 'product_search' ) ) {
+				$clauses['orderby'] = "{$wpdb->posts}.post_title ASC, " . $clauses['orderby'];
+			}
+
+			return $clauses;
+		};
+		add_filter( 'posts_clauses', $lead_with_title, 20, 2 );
+
+		try {
+			$results = $this->get_search_results( $search_phrase );
+		} finally {
+			remove_filter( 'posts_clauses', $lead_with_title, 20 );
+		}
+
+		$this->assertSame(
+			array( $content_match->get_id(), $title_match->get_id() ),
+			$results,
+			'A primary sort set by an earlier posts_clauses filter should take precedence over search relevance.'
+		);
+	}
+
+	/**
+	 * @testdox Product searches still rank when an earlier posts_clauses filter only appends a tiebreak.
+	 */
+	public function test_product_search_ranks_when_earlier_posts_clauses_filter_appends_a_tiebreak(): void {
+		list( $title_match, $content_match, $search_phrase ) = $this->create_search_products();
+
+		// Reading the clause on posts_clauses must not turn a plugin that adds a tiebreak into one that
+		// silently disables relevance: core still owns the primary sort, so ranking leads and this survives.
+		$append_tiebreak = static function ( $clauses, $query ) {
+			global $wpdb;
+
+			if ( is_array( $clauses ) && isset( $clauses['orderby'] ) && $query->get( 'product_search' ) ) {
+				$clauses['orderby'] .= ", {$wpdb->posts}.ID ASC";
+			}
+
+			return $clauses;
+		};
+		add_filter( 'posts_clauses', $append_tiebreak, 20, 2 );
+
+		$captured_orderby = null;
+		$capture          = static function ( $clauses ) use ( &$captured_orderby ) {
+			$captured_orderby = is_array( $clauses ) && isset( $clauses['orderby'] ) ? $clauses['orderby'] : null;
+			return $clauses;
+		};
+		add_filter( 'posts_clauses', $capture, PHP_INT_MAX );
+
+		try {
+			$results = $this->get_search_results( $search_phrase );
+		} finally {
+			remove_filter( 'posts_clauses', $append_tiebreak, 20 );
+			remove_filter( 'posts_clauses', $capture, PHP_INT_MAX );
+		}
+
+		global $wpdb;
+		$this->assertStringContainsString( 'CASE WHEN', (string) $captured_orderby, 'An appended tiebreak should not suppress the relevance clause.' );
+		$this->assertStringEndsWith( "{$wpdb->posts}.ID ASC", (string) $captured_orderby, "The appending filter's tiebreak should survive after the relevance clause." );
+		$this->assertSame(
+			array( $title_match->get_id(), $content_match->get_id() ),
+			$results,
+			'Title matches should still be listed first when an earlier filter only appends a tiebreak.'
+		);
+	}
+
+	/**
+	 * @testdox Product search ranking leaves the core default clause visible to every posts_orderby filter.
+	 */
+	public function test_product_search_ranking_preserves_clause_for_other_posts_orderby_filters(): void {
+		global $wpdb;
+
+		list( $title_match, $content_match, $search_phrase ) = $this->create_search_products();
+
+		// PHP_INT_MAX because ranking runs on posts_clauses: no posts_orderby priority, however late,
+		// should ever observe the relevance clause.
+		$guard_fired = 0;
+		$guard       = static function ( $orderby ) use ( &$guard_fired, $wpdb ) {
+			if ( "{$wpdb->posts}.post_date DESC" === $orderby ) {
+				++$guard_fired;
+			}
+			return $orderby;
+		};
+		add_filter( 'posts_orderby', $guard, PHP_INT_MAX );
+
+		try {
+			$results = $this->get_search_results( $search_phrase );
+		} finally {
+			remove_filter( 'posts_orderby', $guard, PHP_INT_MAX );
+		}
+
+		$this->assertGreaterThan( 0, $guard_fired, 'A posts_orderby filter at any priority that matches on the core default clause should still see it unmodified.' );
+		$this->assertSame(
+			array( $title_match->get_id(), $content_match->get_id() ),
+			$results,
+			'Title ranking should still apply when other filters leave the clause unchanged.'
+		);
+	}
+
+	/**
+	 * Search terms for title-priority coverage.
+	 *
+	 * @return array<string, array<string>>
+	 */
+	public function search_term_provider(): array {
+		return array(
+			'plain search'   => array( '%s' ),
+			'quoted phrase'  => array( '"%s"' ),
+			'first OR group' => array( '%s OR Missing Lantern' ),
+			'later OR group' => array( 'Missing Lantern OR %s' ),
+		);
+	}
+
+	/**
+	 * Products list out-of-stock filter includes variable products with out-of-stock variations.
+	 *
 	 * @testdox Products list out-of-stock filter includes variable products with out-of-stock variations.
 	 */
 	public function test_out_of_stock_filter_includes_variable_products_with_out_of_stock_variations() {
@@ -118,6 +716,57 @@ class WC_Admin_List_Table_Products_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Create title and content-only search matches.
+	 *
+	 * @return array{WC_Product, WC_Product, string}
+	 */
+	private function create_search_products(): array {
+		$search_phrase = 'Night Light ' . wp_generate_password( 8, false );
+
+		// The content-only match is deliberately the newer one, so date ordering alone puts it first and
+		// only ranking can restore the expected order. Undated fixtures tie, and the assertion then rides
+		// on an unspecified tie-break rather than the clause under test.
+		$title_match = WC_Helper_Product::create_simple_product();
+		$title_match->set_name( $search_phrase );
+		$title_match->set_date_created( '2024-01-01 00:00:00' );
+		$title_match->save();
+
+		$content_match = WC_Helper_Product::create_simple_product();
+		$content_match->set_name( 'Archive Lamp Notes ' . wp_generate_password( 8, false ) );
+		$content_match->set_description( $search_phrase );
+		$content_match->set_date_created( '2024-01-02 00:00:00' );
+		$content_match->save();
+
+		return array( $title_match, $content_match, $search_phrase );
+	}
+
+
+	/**
+	 * Run a Products list search.
+	 *
+	 * @param string $search_term Search term.
+	 * @param array  $query_args  Additional query arguments.
+	 * @return int[]
+	 */
+	private function get_search_results( string $search_term, array $query_args = array() ): array {
+		$query_vars = $this->sut->request_query(
+			array_merge(
+				array(
+					'post_type'      => 'product',
+					'post_status'    => 'publish',
+					'posts_per_page' => -1,
+					'fields'         => 'ids',
+					's'              => $search_term,
+				),
+				$query_args
+			)
+		);
+
+		$query = new WP_Query( $query_vars );
+		return array_map( 'intval', $query->get_posts() );
+	}
+
 	/**
 	 * Create a variable product with children that use the supplied stock statuses.
 	 *