Commit 8874024828e for woocommerce

commit 8874024828e85944d0a452c186f1ad14a25e0a1d
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Thu Sep 24 13:36:08 2026 +0100

    Fix published products with a missing parent being hidden from the catalog (#68988)

    * Don't hide published products whose parent post is missing or not a product

    * Add tests for products with missing or non-product parents

    * Add changelog entry for the missing parent visibility fix

diff --git a/plugins/woocommerce/changelog/fix-missing-post-parent-hides-product b/plugins/woocommerce/changelog/fix-missing-post-parent-hides-product
new file mode 100644
index 00000000000..79e39b8c893
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-missing-post-parent-hides-product
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Restore visibility and purchasability of published products whose post_parent points at a missing or non-product post
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index 6296c0a1cf5..4580ce93415 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -1715,29 +1715,31 @@ class WC_Product extends WC_Abstract_Legacy_Product {

 	/**
 	 * Whether the current user can view this product: it is published, or they can edit it.
-	 * A variation additionally requires its parent to be viewable.
+	 * A variation additionally requires its parent product to be viewable.
 	 *
 	 * @since 11.1.0
 	 * @return bool
 	 */
 	public function is_viewable() {
-		$parent_id = $this->get_parent_id();
+		$parent_id          = $this->get_parent_id();
+		$has_product_parent = $parent_id && in_array( get_post_type( $parent_id ), array( 'product', 'product_variation' ), true );

 		return ( ProductStatus::PUBLISH === $this->get_status() || current_user_can( 'edit_post', $this->get_id() ) )
-			&& ( ! $parent_id || ProductStatus::PUBLISH === get_post_status( $parent_id ) || current_user_can( 'edit_post', $parent_id ) );
+			&& ( ! $has_product_parent || ProductStatus::PUBLISH === get_post_status( $parent_id ) || current_user_can( 'edit_post', $parent_id ) );
 	}

 	/**
-	 * Whether this product is publicly viewable: the product and its parent (if it has one) are published.
+	 * Whether this product is publicly viewable: the product and its parent product (if it has one) are published.
 	 *
 	 * @since 11.1.0
 	 * @return bool
 	 */
 	public function is_publicly_viewable() {
-		$parent_id = $this->get_parent_id();
+		$parent_id          = $this->get_parent_id();
+		$has_product_parent = $parent_id && in_array( get_post_type( $parent_id ), array( 'product', 'product_variation' ), true );

 		return ProductStatus::PUBLISH === $this->get_status()
-			&& ( ! $parent_id || ProductStatus::PUBLISH === get_post_status( $parent_id ) );
+			&& ( ! $has_product_parent || ProductStatus::PUBLISH === get_post_status( $parent_id ) );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php
index 6fe986b5280..9168b9a2e02 100644
--- a/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php
+++ b/plugins/woocommerce/src/Internal/ShopperLists/ShopperListItem.php
@@ -3,7 +3,6 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Internal\ShopperLists;

-use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductType;

 /**
@@ -134,7 +133,7 @@ class ShopperListItem {
 	 */
 	public static function from_product( int $product_or_variation_id, array $variation = array(), int $quantity = 1 ): ?self {
 		$product = wc_get_product( absint( $product_or_variation_id ) );
-		if ( ! $product || ! self::product_is_live( $product ) ) {
+		if ( ! $product || ! $product->is_publicly_viewable() ) {
 			return null;
 		}

@@ -313,29 +312,7 @@ class ShopperListItem {
 	 */
 	public function is_live(): bool {
 		$product = $this->get_product();
-		return $product instanceof \WC_Product && self::product_is_live( $product );
-	}
-
-	/**
-	 * Whether a resolved product (and its parent, for variations) is `publish`.
-	 *
-	 * @param \WC_Product $product Resolved product or variation.
-	 */
-	private static function product_is_live( \WC_Product $product ): bool {
-		if ( ProductStatus::PUBLISH !== $product->get_status() ) {
-			return false;
-		}
-
-		$parent_id = $product->get_parent_id();
-		if ( $parent_id > 0 ) {
-			$parent = wc_get_product( $parent_id );
-
-			if ( ! $parent instanceof \WC_Product || ProductStatus::PUBLISH !== $parent->get_status() ) {
-				return false;
-			}
-		}
-
-		return true;
+		return $product instanceof \WC_Product && $product->is_publicly_viewable();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
index eb81dc5e3ec..58d636fce7b 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
@@ -1,5 +1,6 @@
 <?php

+use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareUnitTestSuiteTrait;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;

@@ -541,4 +542,51 @@ class WC_Abstract_Product_Test extends WC_Unit_Test_Case {
 		$this->assertTrue( $product->is_viewable(), "A $status product is viewable by admins." );
 		$this->assertFalse( $product->is_publicly_viewable(), "A $status product is never publicly viewable, even for admins." );
 	}
+
+	/**
+	 * @testdox A missing or non-product parent doesn't affect visibility, but an unpublished product parent hides the product from the public.
+	 * @testWith ["missing", true]
+	 *           ["draft_page", true]
+	 *           ["draft_product", false]
+	 *           ["published_product", true]
+	 * @param string $parent_kind        Kind of post the parent ID points at.
+	 * @param bool   $visible_logged_out Whether the product should be visible to a logged-out user.
+	 */
+	public function test_is_viewable_with_parent( $parent_kind, $visible_logged_out ) {
+		switch ( $parent_kind ) {
+			case 'missing':
+				$parent_id = 987654321;
+				break;
+			case 'draft_page':
+				$parent_id = self::factory()->post->create(
+					array(
+						'post_type'   => 'page',
+						'post_status' => 'draft',
+					)
+				);
+				break;
+			case 'draft_product':
+			case 'published_product':
+				$parent = WC_Helper_Product::create_simple_product();
+				$parent->set_status( 'draft_product' === $parent_kind ? ProductStatus::DRAFT : ProductStatus::PUBLISH );
+				$parent->save();
+				$parent_id = $parent->get_id();
+				break;
+			default:
+				$this->fail( "Unhandled parent kind: $parent_kind." );
+		}
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_parent_id( $parent_id );
+		$product->save();
+
+		wp_set_current_user( 0 );
+		$this->assertSame( $visible_logged_out, $product->is_viewable(), "A product with a $parent_kind parent has unexpected is_viewable() when logged out." );
+		$this->assertSame( $visible_logged_out, $product->is_publicly_viewable(), "A product with a $parent_kind parent has unexpected is_publicly_viewable()." );
+		$this->assertSame( $visible_logged_out, $product->is_visible(), "A product with a $parent_kind parent has unexpected is_visible() when logged out." );
+		$this->assertSame( $visible_logged_out, $product->is_purchasable(), "A product with a $parent_kind parent has unexpected is_purchasable() when logged out." );
+
+		wp_set_current_user( $this->admin_user );
+		$this->assertTrue( $product->is_viewable(), "A product with a $parent_kind parent is viewable by admins." );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListItemSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListItemSchemaTest.php
index 89d9406675a..986c7357c6d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListItemSchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/ShopperListItemSchemaTest.php
@@ -133,7 +133,7 @@ class ShopperListItemSchemaTest extends WC_Unit_Test_Case {
 			$product->set_catalog_visibility( $overrides['catalog_visibility'] );
 			$product->save();
 		}
-		$post_overrides = array_intersect_key( $overrides, array_flip( array( 'post_status', 'post_password' ) ) );
+		$post_overrides = array_intersect_key( $overrides, array_flip( array( 'post_status', 'post_password', 'post_parent' ) ) );
 		if ( ! empty( $post_overrides ) ) {
 			// `wp_update_post` silently rewrites `future` back to `publish` when post_date is in the past,
 			// so a future date is needed to actually persist the status.
@@ -158,7 +158,7 @@ class ShopperListItemSchemaTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @return array<string, array{0: array<string, string>, 1: bool}>
+	 * @return array<string, array{0: array<string, string|int>, 1: bool}>
 	 */
 	public function provider_is_live_cases(): array {
 		return array(
@@ -166,6 +166,7 @@ class ShopperListItemSchemaTest extends WC_Unit_Test_Case {
 			// tombstone deliberately-saved OOS / catalog-hidden items.
 			'OOS, publish'              => array( array( 'stock_status' => 'outofstock' ), true ),
 			'catalog_visibility=hidden' => array( array( 'catalog_visibility' => 'hidden' ), true ),
+			'missing parent post'       => array( array( 'post_parent' => 987654321 ), true ),
 			// Tombstone cases.
 			'draft'                     => array( array( 'post_status' => 'draft' ), false ),
 			'pending'                   => array( array( 'post_status' => 'pending' ), false ),