Commit 1bbe00c7560 for woocommerce

commit 1bbe00c756088af554dc7fcd6be5b5667cc37b4c
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Tue Aug 11 14:05:14 2026 +0200

    Fix external product storefront visibility (#67609)

    * Fix external product storefront visibility

    * Add changelog entry for external product visibility fix

diff --git a/plugins/woocommerce/changelog/wooairr-21-external-product-visibility b/plugins/woocommerce/changelog/wooairr-21-external-product-visibility
new file mode 100644
index 00000000000..2f03ed16b87
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooairr-21-external-product-visibility
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep external products visible in the catalog after converting them from an out-of-stock product.
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
index c48d1b713fe..0119051d840 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
@@ -467,8 +467,8 @@ class WC_Meta_Box_Product_Data {
 	 * Persist the stock data enforced by the external product class.
 	 *
 	 * External product setters normalize stock values while the existing product data is read. The submitted
-	 * values therefore match the in-memory values and are not detected as changes, leaving stale post meta and
-	 * lookup data behind unless they are synchronized explicitly.
+	 * values therefore match the in-memory values and are not detected as changes, leaving stale stock meta,
+	 * lookup, and visibility data behind unless they are synchronized explicitly.
 	 *
 	 * @param WC_Product $product External product object.
 	 */
@@ -476,6 +476,7 @@ class WC_Meta_Box_Product_Data {
 		update_post_meta( $product->get_id(), '_manage_stock', 'no' );
 		update_post_meta( $product->get_id(), '_stock_status', ProductStockStatus::IN_STOCK );
 		update_post_meta( $product->get_id(), '_backorders', 'no' );
+		wp_remove_object_terms( $product->get_id(), ProductStockStatus::OUT_OF_STOCK, 'product_visibility' );

 		/**
 		 * Product data store.
diff --git a/plugins/woocommerce/tests/e2e/playwright.config.ts b/plugins/woocommerce/tests/e2e/playwright.config.ts
index cf3e604a7b4..c23bff2c0a4 100644
--- a/plugins/woocommerce/tests/e2e/playwright.config.ts
+++ b/plugins/woocommerce/tests/e2e/playwright.config.ts
@@ -165,6 +165,9 @@ const serialRunSpecs = [
 	// Imports a fixed-content CSV (fixed SKUs/names) and asserts the imported rows
 	// on the store-wide product list — collides with concurrently created products.
 	'**/tests/product/product-import-csv.spec.ts',
+	// Toggles the global out-of-stock catalog visibility setting while verifying
+	// that converted external products remain visible on the storefront.
+	'**/tests/product/product-grouped-stock-status.spec.ts',
 	// Mutate global WooCommerce settings (store address/currency/country, tax)
 	// that other workers' cart/checkout/storefront specs depend on.
 	'**/tests/settings/settings-general.spec.ts',
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-grouped-stock-status.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-grouped-stock-status.spec.ts
index bc2387e3250..9c9558edd6c 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-grouped-stock-status.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-grouped-stock-status.spec.ts
@@ -1,7 +1,8 @@
 /**
  * External dependencies
  */
-import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';
+import type { Page } from '@playwright/test';
+import { type ApiClient, WC_API_PATH } from '@woocommerce/e2e-utils-playwright';

 /**
  * Internal dependencies
@@ -29,61 +30,126 @@ const test = baseTest.extend( {
 	},
 } );

-for ( const productType of [ 'grouped', 'external' ] ) {
-	test( `resets stock settings when converting a product to ${ productType }`, async ( {
+type ProductType = 'grouped' | 'external';
+type Product = {
+	id: number;
+	name: string;
+};
+
+async function convertProductType( page: Page, productType: ProductType ) {
+	await page.locator( '#product-type' ).selectOption( productType );
+	await page
+		.locator( '#publishing-action' )
+		.getByRole( 'button', { name: 'Update' } )
+		.click();
+}
+
+async function expectProductStockReset(
+	page: Page,
+	restApi: ApiClient,
+	product: Product,
+	productType: ProductType
+) {
+	await expect( page.locator( 'input#_manage_stock' ) ).not.toBeChecked();
+	await expect(
+		page.locator( 'input[name="_stock_status"][value="instock"]' )
+	).toBeChecked();
+	await expect(
+		page
+			.locator( 'div.notice-success > p' )
+			.filter( { hasText: 'Product updated' } )
+	).toBeVisible();
+
+	const response = await restApi.get(
+		`${ WC_API_PATH }/products/${ product.id }`
+	);
+
+	expect( response.data.type ).toBe( productType );
+	expect( response.data.manage_stock ).toBe( false );
+	expect( response.data.stock_status ).toBe( 'instock' );
+
+	const productSearch = encodeURIComponent( product.name );
+	await page.goto(
+		`wp-admin/edit.php?post_type=product&stock_status=instock&s=${ productSearch }`
+	);
+	await expect( page.locator( `#post-${ product.id }` ) ).toBeVisible();
+
+	await page.goto(
+		`wp-admin/edit.php?post_type=product&stock_status=outofstock&s=${ productSearch }`
+	);
+	await expect( page.locator( `#post-${ product.id }` ) ).toHaveCount( 0 );
+}
+
+test( 'resets stock settings when converting a product to grouped', async ( {
+	page,
+	restApi,
+	managedOutOfStockProduct,
+} ) => {
+	await page.goto(
+		`wp-admin/post.php?post=${ managedOutOfStockProduct.id }&action=edit`
+	);
+	await expect( page.locator( 'input#_manage_stock' ) ).toBeChecked();
+	await expect(
+		page.locator( 'input[name="_stock_status"][value="outofstock"]' )
+	).toBeChecked();
+
+	await convertProductType( page, 'grouped' );
+	await expectProductStockReset(
 		page,
 		restApi,
 		managedOutOfStockProduct,
-	} ) => {
+		'grouped'
+	);
+} );
+
+test( 'resets external product stock settings and keeps it visible when out-of-stock products are hidden', async ( {
+	page,
+	restApi,
+	managedOutOfStockProduct,
+} ) => {
+	const hideOutOfStockSettingEndpoint = `${ WC_API_PATH }/settings/products/woocommerce_hide_out_of_stock_items`;
+	const hideOutOfStockSetting = await restApi.get(
+		hideOutOfStockSettingEndpoint
+	);
+	const productSearch = encodeURIComponent( managedOutOfStockProduct.name );
+
+	await restApi.put( hideOutOfStockSettingEndpoint, { value: 'yes' } );
+
+	try {
+		await page.goto( `shop/?s=${ productSearch }` );
+		await expect(
+			page.getByRole( 'heading', {
+				name: managedOutOfStockProduct.name,
+				exact: true,
+			} )
+		).toHaveCount( 0 );
+
 		await page.goto(
 			`wp-admin/post.php?post=${ managedOutOfStockProduct.id }&action=edit`
 		);
-
 		await expect( page.locator( 'input#_manage_stock' ) ).toBeChecked();
 		await expect(
 			page.locator( 'input[name="_stock_status"][value="outofstock"]' )
 		).toBeChecked();

-		await page.locator( '#product-type' ).selectOption( productType );
-
-		await expect( page.locator( 'input#_manage_stock' ) ).not.toBeChecked();
-		await expect(
-			page.locator( 'input[name="_stock_status"][value="instock"]' )
-		).toBeChecked();
-
-		await page
-			.locator( '#publishing-action' )
-			.getByRole( 'button', { name: 'Update' } )
-			.click();
-		await expect(
-			page
-				.locator( 'div.notice-success > p' )
-				.filter( { hasText: 'Product updated' } )
-		).toBeVisible();
-
-		const response = await restApi.get(
-			`${ WC_API_PATH }/products/${ managedOutOfStockProduct.id }`
+		await convertProductType( page, 'external' );
+		await expectProductStockReset(
+			page,
+			restApi,
+			managedOutOfStockProduct,
+			'external'
 		);

-		expect( response.data.type ).toBe( productType );
-		expect( response.data.manage_stock ).toBe( false );
-		expect( response.data.stock_status ).toBe( 'instock' );
-
-		const productSearch = encodeURIComponent(
-			managedOutOfStockProduct.name
-		);
-		await page.goto(
-			`wp-admin/edit.php?post_type=product&stock_status=instock&s=${ productSearch }`
-		);
+		await page.goto( `shop/?s=${ productSearch }` );
 		await expect(
-			page.locator( `#post-${ managedOutOfStockProduct.id }` )
+			page.getByRole( 'heading', {
+				name: managedOutOfStockProduct.name,
+				exact: true,
+			} )
 		).toBeVisible();
-
-		await page.goto(
-			`wp-admin/edit.php?post_type=product&stock_status=outofstock&s=${ productSearch }`
-		);
-		await expect(
-			page.locator( `#post-${ managedOutOfStockProduct.id }` )
-		).toHaveCount( 0 );
-	} );
-}
+	} finally {
+		await restApi.put( hideOutOfStockSettingEndpoint, {
+			value: hideOutOfStockSetting.data.value,
+		} );
+	}
+} );