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,
+ } );
+ }
+} );