Commit cd324a40985 for woocommerce

commit cd324a409850e6ec422e6d1f2864d9b7815c9637
Author: theAverageDev (Luca Tumedei) <luca.tumedei@automattic.com>
Date:   Fri Jun 26 10:42:13 2026 +0200

    Fix Product Collection upsells fatal on unresolved reference (#65909)

    * Fix PHP fatal in Product Collection upsells when reference is unresolved

    The upsells build_query mapped wc_get_product over the reference and
    dereferenced get_upsell_ids() without dropping false results. When the
    editor preview renders with no resolved reference, the reference arrives
    as [ null ]; wc_get_product( null ) returns false and the deref fataled
    (HTTP 500). Wrap the map in array_filter, mirroring the cross-sells
    handler, so the existing empty() guard returns a no-results query.

    * Add changelog entry for upsells null-deref fix

    * Drop resolved upsells get_upsell_ids error from PHPStan baseline

    The array_filter guard makes $products an array<WC_Product>, so PHPStan no
    longer reports the method.nonObject error that was baselined for this call.

    * Stop Product Collection cross-sells leaking unresolved references

    Diff cross-sell recommendations against the raw product references instead
    of the resolved ids, so a reference that no longer resolves to a product
    cannot leak into post__in. Matches the upsells handler.

    * Make Product Collection related-products handler test deterministic

    The test forced woocommerce_product_related_posts_force_display, which turns
    the related-products data store on (not off) and pulls existing database
    products into the result. Drop the filter so the reference with no cats/tags
    short-circuits to an empty set, independent of database contents.

    * Add upsells unresolvable-reference regression test

    Mirrors the cross-sells test: a cart reference that no longer resolves must not fatal or leak into the upsells results. Guards the array_filter null-deref fix on the cart build_frontend_query path.

    * Drop redundant test-determinism changelog entry

    The fix changelog entry already covers this PR; a test-only tweak does not
    warrant a separate user-facing entry.

    * Dedupe Product Collection unresolvable-reference handler tests

    Collapse the upsells and cross-sells variants into one
    @dataProvider-driven test; the provider supplies the collection name and a
    callback assigning the related ids.

diff --git a/plugins/woocommerce/changelog/fix-product-collection-upsells-null-deref b/plugins/woocommerce/changelog/fix-product-collection-upsells-null-deref
new file mode 100644
index 00000000000..4d1725a3f4e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-collection-upsells-null-deref
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix PHP fatal in the Product Collection upsells handler when the product reference is unresolved (editor preview), and stop the cross-sells handler from leaking unresolved product references into its results.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 4bb5b3f118c..71ab5275e35 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -52161,12 +52161,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php

-		-
-			message: '#^Cannot call method get_upsell_ids\(\) on WC_Product\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductCollection\\HandlerRegistry\:\:get_cart_product_ids\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
 			identifier: missingType.generics
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
index 1d489acd0f5..778299940ae 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
@@ -198,7 +198,7 @@ class HandlerRegistry {
 					);
 				}

-				$products = array_map( 'wc_get_product', $product_reference );
+				$products = array_filter( array_map( 'wc_get_product', $product_reference ) );

 				if ( empty( $products ) ) {
 					return array(
@@ -281,13 +281,6 @@ class HandlerRegistry {
 					);
 				}

-				$product_ids = array_map(
-					function ( $product ) {
-						return $product->get_id();
-					},
-					$products
-				);
-
 				$all_cross_sells = array_reduce(
 					$products,
 					function ( $acc, $product ) {
@@ -302,7 +295,7 @@ class HandlerRegistry {
 				// Remove duplicates and product references. We don't want to display
 				// what's already in cart.
 				$unique_cross_sells = array_unique( $all_cross_sells );
-				$cross_sells        = array_diff( $unique_cross_sells, $product_ids );
+				$cross_sells        = array_diff( $unique_cross_sells, $product_reference );

 				return array(
 					'post__in' => empty( $cross_sells ) ? array( -1 ) : $cross_sells,
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
index 5fb434cb00a..7f1383d52b2 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/HandlerRegistry.php
@@ -175,8 +175,11 @@ class HandlerRegistry extends \WP_UnitTestCase {

 		$expected_product_ids = array( 2, 3, 4 );

-		// This filter will turn off the data store so we don't need dummy products.
-		add_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 );
+		// Reference 1 has no categories or tags; keep force-display off so
+		// wc_get_related_products() short-circuits to an empty set instead of querying the
+		// data store. The mocked woocommerce_related_products filter then drives the result,
+		// independent of any products in the database.
+		add_filter( 'woocommerce_product_related_posts_force_display', '__return_false', 0 );
 		$related_filter->expects( $this->exactly( 2 ) )
 			->method( '__invoke' )
 			->with( array(), 1 )
@@ -201,7 +204,7 @@ class HandlerRegistry extends \WP_UnitTestCase {
 		);
 		$result_editor = $this->block_instance->update_rest_query_in_editor( array(), $request );

-		remove_filter( 'woocommerce_product_related_posts_force_display', '__return_true', 0 );
+		remove_filter( 'woocommerce_product_related_posts_force_display', '__return_false', 0 );
 		remove_filter( 'woocommerce_related_products', array( $related_filter, '__invoke' ) );

 		$this->assertEqualsCanonicalizing( $expected_product_ids, $result_frontend['post__in'] );
@@ -239,6 +242,96 @@ class HandlerRegistry extends \WP_UnitTestCase {
 		$this->assertEqualsCanonicalizing( $expected_product_ids, $result_editor['post__in'] );
 	}

+	/**
+	 * @testdox Upsells collection returns a no-results query when the product reference is missing instead of erroring.
+	 */
+	public function test_collection_upsells_missing_reference_returns_no_results() {
+		// The editor renders the preview before a product reference is resolved,
+		// so the reference arrives as array( null ) and wc_get_product( null ) is false.
+		$request = Utils::build_request();
+		$request->set_param(
+			'productCollectionQueryContext',
+			array(
+				'collection' => 'woocommerce/product-collection/upsells',
+			)
+		);
+
+		$result = $this->block_instance->update_rest_query_in_editor( array(), $request );
+
+		$this->assertSame( array( -1 ), $result['post__in'], 'A missing upsells reference should yield a no-results query, not a fatal.' );
+	}
+
+	/**
+	 * Cart-based collections keyed by name, each with a callback assigning related ids to a product.
+	 *
+	 * @return array<string, array{string, callable}>
+	 */
+	public function provider_cart_collections_with_related_ids(): array {
+		return array(
+			'upsells'     => array(
+				'woocommerce/product-collection/upsells',
+				function ( $product, $ids ) {
+					$product->set_upsell_ids( $ids );
+				},
+			),
+			'cross-sells' => array(
+				'woocommerce/product-collection/cross-sells',
+				function ( $product, $ids ) {
+					$product->set_cross_sell_ids( $ids );
+				},
+			),
+		);
+	}
+
+	/**
+	 * @testdox Cart collection does not leak a non-resolving reference id into the results.
+	 *
+	 * @dataProvider provider_cart_collections_with_related_ids
+	 *
+	 * @param string   $collection      Collection name, e.g. woocommerce/product-collection/upsells.
+	 * @param callable $set_related_ids Receives the cart product and the related ids to assign to it.
+	 */
+	public function test_cart_collection_excludes_unresolvable_reference_from_results( string $collection, callable $set_related_ids ) {
+		// A cart reference can stop resolving mid-session (e.g. the product is deleted). It must not
+		// surface in the results even if a resolved cart product still lists it among its related ids.
+		$cart_product = WC_Helper_Product::create_simple_product( false );
+		$real_related = WC_Helper_Product::create_simple_product();
+
+		// An id guaranteed not to resolve to a product.
+		$ghost      = WC_Helper_Product::create_simple_product();
+		$missing_id = $ghost->get_id();
+		$ghost->delete( true );
+
+		$real_related_id = $real_related->get_id();
+		$set_related_ids( $cart_product, array( $real_related_id, $missing_id ) );
+		$cart_product->save();
+
+		$parsed_block                        = Utils::get_base_parsed_block();
+		$parsed_block['attrs']['collection'] = $collection;
+		$this->block_instance->set_parsed_block( $parsed_block );
+
+		$block                                       = new \stdClass();
+		$block->context                              = $parsed_block['attrs'];
+		$block->context['productCollectionLocation'] = array(
+			'type'       => 'cart',
+			'sourceData' => array(
+				'productIds' => array( $cart_product->get_id(), $missing_id ),
+			),
+		);
+
+		$query_args = $this->block_instance->build_frontend_query( array(), $block, 1 );
+
+		// Delete the products now. If the assertion fails the products would be left over.
+		$cart_product->delete( true );
+		$real_related->delete( true );
+
+		$this->assertSame(
+			array( $real_related_id ),
+			array_values( $query_args['post__in'] ),
+			'Only the resolvable reference should remain; the non-resolving reference id must not leak.'
+		);
+	}
+
 	/**
 	 * Tests that the cross-sells collection handler works as expected.
 	 */