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.
*/