Commit 93ceec71206 for woocommerce
commit 93ceec71206cf09ce7fa15d157c8d013e534e397
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Wed Sep 30 13:03:06 2026 +0100
Load Shop page choices on demand in classic Products settings (#69068)
* fix: paginate classic Shop page searches
* chore: add changelog for Shop page search
* fix: simplify Shop page selection to capped search
* chore: update Shop page search changelog
* fix: show initial Shop page search choices
* style: align Shop page search attributes
* refactor: simplify page search minimum length check
* test: remove Shop page rendering implementation checks
* chore: remove obsolete settings PHPStan suppression
* test: avoid leaking request state from page search test
* test: exercise Settings UI through inventory controls
diff --git a/plugins/woocommerce/changelog/fix-classic-shop-page-search b/plugins/woocommerce/changelog/fix-classic-shop-page-search
new file mode 100644
index 00000000000..0b62baffe1b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-classic-shop-page-search
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent excessive memory usage in Products settings by loading Shop page choices on demand with a limited search.
diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-enhanced-select.js b/plugins/woocommerce/client/legacy/js/admin/wc-enhanced-select.js
index c8376660196..396650eeb07 100644
--- a/plugins/woocommerce/client/legacy/js/admin/wc-enhanced-select.js
+++ b/plugins/woocommerce/client/legacy/js/admin/wc-enhanced-select.js
@@ -326,16 +326,16 @@ jQuery( function ( $ ) {
$( ':input.wc-page-search' )
.filter( ':not(.enhanced)' )
.each( function () {
+ var minimumInputLength = $( this ).data(
+ 'minimum_input_length'
+ );
var select2_args = {
allowClear: $( this ).data( 'allow_clear' )
? true
: false,
placeholder: $( this ).data( 'placeholder' ),
- minimumInputLength: $( this ).data(
- 'minimum_input_length'
- )
- ? $( this ).data( 'minimum_input_length' )
- : '3',
+ minimumInputLength:
+ minimumInputLength != null ? minimumInputLength : 3,
escapeMarkup: function ( m ) {
return m;
},
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
index 32a96325dfc..ff47cc59f03 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
@@ -720,10 +720,9 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
case 'single_select_page_with_search':
$option_value = $value['value'];
- $page = get_post( $option_value );
+ $page = $option_value ? get_post( $option_value ) : null;
if ( ! is_null( $page ) ) {
- $page = get_post( $option_value );
$option_display_name = sprintf(
/* translators: 1: page name 2: page ID */
__( '%1$s (ID: %2$s)', 'woocommerce' ),
@@ -745,7 +744,7 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
<?php echo implode( ' ', $custom_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
data-placeholder="<?php esc_attr_e( 'Search for a page…', 'woocommerce' ); ?>"
data-allow_clear="true"
- data-exclude="<?php echo wc_esc_json( wp_json_encode( $value['args']['exclude'] ) ); ?>"
+ data-exclude="<?php echo wc_esc_json( wp_json_encode( $value['args']['exclude'] ?? array() ) ); ?>"
>
<option value=""></option>
<?php if ( ! is_null( $page ) ) { ?>
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-products.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-products.php
index f3fe6fd4419..d2e6b72f302 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-products.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-products.php
@@ -82,15 +82,19 @@ class WC_Settings_Products extends WC_Settings_Page {
'id' => 'catalog_options',
),
array(
- 'title' => __( 'Shop page', 'woocommerce' ),
+ 'title' => __( 'Shop page', 'woocommerce' ),
/* translators: %s: URL to settings. */
- 'desc' => sprintf( __( 'The base page can also be used in your <a href="%s">product permalinks</a>.', 'woocommerce' ), admin_url( 'options-permalink.php' ) ),
- 'id' => 'woocommerce_shop_page_id',
- 'type' => 'single_select_page',
- 'default' => '',
- 'class' => 'wc-enhanced-select-nostd',
- 'css' => 'min-width:300px;',
- 'desc_tip' => __( 'This sets the base page of your shop - this is where your product archive will be.', 'woocommerce' ),
+ 'desc' => sprintf( __( 'The base page can also be used in your <a href="%s">product permalinks</a>.', 'woocommerce' ), admin_url( 'options-permalink.php' ) ),
+ 'id' => 'woocommerce_shop_page_id',
+ 'type' => 'single_select_page_with_search',
+ 'default' => '',
+ 'class' => 'wc-page-search',
+ 'custom_attributes' => array(
+ 'data-limit' => 20,
+ 'data-minimum_input_length' => 0,
+ ),
+ 'css' => 'min-width:300px;',
+ 'desc_tip' => __( 'This sets the base page of your shop - this is where your product archive will be.', 'woocommerce' ),
),
array(
'title' => __( 'Add to cart behaviour', 'woocommerce' ),
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 94088f246ce..e2d39619fec 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -2748,12 +2748,6 @@ parameters:
count: 1
path: includes/admin/class-wc-admin-settings.php
- -
- message: '#^Cannot access property \$post_title on WP_Post\|null\.$#'
- identifier: property.nonObject
- count: 1
- path: includes/admin/class-wc-admin-settings.php
-
-
message: '#^Cannot call method remove_help_tabs\(\) on WP_Screen\|null\.$#'
identifier: method.nonObject
diff --git a/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts b/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts
index 2bf37d365a8..456746dfd1d 100644
--- a/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/settings/settings-ui-feature-flag.spec.ts
@@ -35,8 +35,8 @@ const recordCompatibilityFailure = (
}
};
-const getUpdatedWeightUnit = ( originalUnit: string ): string =>
- originalUnit === 'kg' ? 'g' : 'kg';
+const getUpdatedStockFormat = ( originalFormat: string ): string =>
+ originalFormat === 'no_amount' ? 'low_amount' : 'no_amount';
const getBaseURL = ( baseURL: string | undefined ): string => {
if ( ! baseURL ) {
@@ -125,7 +125,9 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
recordCompatibilityFailure( compatibilityFailures, message.text() );
} );
- await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=products' );
+ await page.goto(
+ 'wp-admin/admin.php?page=wc-settings&tab=products§ion=inventory'
+ );
await expect( page.locator( '[data-wc-settings-ui]' ) ).toBeVisible();
await expect(
page.locator( 'link[href*="/settings-ui/style.css"]' )
@@ -144,15 +146,14 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
settingsUI.locator( '.wc-settings-ui__section-card' )
).toHaveCount( 0 );
- const weightUnit = settingsUI.getByLabel( 'Weight unit' );
- expect( [ 'kg', 'g', 'lbs', 'oz' ] ).toContain(
- await weightUnit.inputValue()
+ const stockFormat = settingsUI.getByLabel( 'Stock display format' );
+ expect( [ '', 'low_amount', 'no_amount' ] ).toContain(
+ await stockFormat.inputValue()
);
- await expect( weightUnit.locator( 'option' ) ).toHaveText( [
- 'kg',
- 'g',
- 'lbs',
- 'oz',
+ await expect( stockFormat.locator( 'option' ) ).toHaveText( [
+ 'Always show quantity remaining in stock e.g. "12 in stock"',
+ 'Only show quantity remaining in stock when low e.g. "Only 2 left in stock"',
+ 'Never show quantity remaining in stock',
] );
const dataFormGlobalType = await page.evaluate( () => {
@@ -170,18 +171,20 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
test( 'saves a DataForm edit through the form post round-trip', async ( {
page,
} ) => {
- await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=products' );
+ await page.goto(
+ 'wp-admin/admin.php?page=wc-settings&tab=products§ion=inventory'
+ );
const settingsUI = page.locator( '[data-wc-settings-ui]' );
await expect( settingsUI ).toBeVisible();
- const weightUnit = settingsUI.getByLabel( 'Weight unit' );
- const originalUnit = await weightUnit.inputValue();
- const updatedUnit = getUpdatedWeightUnit( originalUnit );
+ const stockFormat = settingsUI.getByLabel( 'Stock display format' );
+ const originalFormat = await stockFormat.inputValue();
+ const updatedFormat = getUpdatedStockFormat( originalFormat );
const saveButton = settingsUI.getByRole( 'button', { name: 'Save' } );
try {
await expect( saveButton ).toBeDisabled();
- await weightUnit.selectOption( updatedUnit );
+ await stockFormat.selectOption( updatedFormat );
await expect( saveButton ).toBeEnabled();
await saveButton.click();
@@ -189,48 +192,54 @@ test.describe( 'Settings UI feature flag', { tag: [ tags.NOT_E2E ] }, () => {
page.getByText( 'Your settings have been saved.' )
).toBeVisible();
await page.reload();
- await expect( settingsUI.getByLabel( 'Weight unit' ) ).toHaveValue(
- updatedUnit
- );
+ await expect(
+ settingsUI.getByLabel( 'Stock display format' )
+ ).toHaveValue( updatedFormat );
} finally {
// The save persists a site-wide option, so restore it even when an
// assertion fails. Plugins and themes are skipped because booting
// the extensions earlier specs install can exhaust the CLI
// container's memory before the command runs.
- await wpCLI(
- `wp option update woocommerce_weight_unit ${ originalUnit } --skip-plugins --skip-themes`
- );
+ await wpCLI( [
+ 'wp',
+ 'option',
+ 'update',
+ 'woocommerce_stock_format',
+ originalFormat,
+ '--skip-plugins',
+ '--skip-themes',
+ ] );
}
} );
test( 'toggles dependent fields with a visibility rule', async ( {
page,
} ) => {
- await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=products' );
+ await page.goto(
+ 'wp-admin/admin.php?page=wc-settings&tab=products§ion=inventory'
+ );
const settingsUI = page.locator( '[data-wc-settings-ui]' );
await expect( settingsUI ).toBeVisible();
- const enableReviews = settingsUI.getByLabel( 'Enable product reviews' );
- const verifiedOwnerLabel = settingsUI.getByLabel(
- 'Show "verified owner" label on customer reviews'
- );
- const verifiedOwnersOnly = settingsUI.getByLabel(
- 'Reviews can only be left by "verified owners"'
+ const manageStock = settingsUI.getByLabel( 'Enable stock management' );
+ const holdStock = settingsUI.getByLabel( 'Hold stock (minutes)' );
+ const lowStockNotifications = settingsUI.getByLabel(
+ 'Enable low stock notifications'
);
const saveButton = settingsUI.getByRole( 'button', { name: 'Save' } );
- await expect( enableReviews ).toBeChecked();
- await expect( verifiedOwnerLabel ).toBeVisible();
- await expect( verifiedOwnersOnly ).toBeVisible();
+ await expect( manageStock ).toBeChecked();
+ await expect( holdStock ).toBeVisible();
+ await expect( lowStockNotifications ).toBeVisible();
- await enableReviews.uncheck();
- await expect( verifiedOwnerLabel ).toHaveCount( 0 );
- await expect( verifiedOwnersOnly ).toHaveCount( 0 );
+ await manageStock.uncheck();
+ await expect( holdStock ).toHaveCount( 0 );
+ await expect( lowStockNotifications ).toHaveCount( 0 );
await expect( saveButton ).toBeEnabled();
- await enableReviews.check();
- await expect( verifiedOwnerLabel ).toBeVisible();
- await expect( verifiedOwnersOnly ).toBeVisible();
+ await manageStock.check();
+ await expect( holdStock ).toBeVisible();
+ await expect( lowStockNotifications ).toBeVisible();
// Restoring the original value leaves nothing to save.
await expect( saveButton ).toBeDisabled();
} );
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php
index 286ee36d365..7385143d2c9 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php
@@ -29,6 +29,32 @@ class WC_Admin_Settings_Test extends WC_Unit_Test_Case {
parent::tearDown();
}
+ /**
+ * @testdox Should not use the global post as the selection when a searchable page setting is empty.
+ */
+ public function test_empty_page_search_does_not_select_global_post(): void {
+ $GLOBALS['post'] = self::factory()->post->create_and_get( array( 'post_title' => 'Unrelated global post' ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Exercise the global-post fallback; parent teardown restores it.
+
+ $fields = array(
+ array(
+ 'id' => 'empty_page',
+ 'type' => 'single_select_page_with_search',
+ 'value' => 0,
+ ),
+ );
+
+ ob_start();
+ try {
+ WC_Admin_Settings::output_fields( $fields );
+ $output = (string) ob_get_contents();
+ } finally {
+ ob_end_clean();
+ }
+
+ $this->assertStringNotContainsString( 'Unrelated global post', $output );
+ $this->assertSame( 1, substr_count( $output, '<option ' ) );
+ }
+
/**
* @testdox Should preserve percent-encoded sequences in password fields.
*/
diff --git a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-products-test.php b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-products-test.php
index f19389fc8c5..30e6d16feed 100644
--- a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-products-test.php
+++ b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-products-test.php
@@ -88,7 +88,7 @@ class WC_Settings_Products_Test extends WC_Settings_Unit_Test_Case {
$expected = array(
'catalog_options' => array( 'title', 'sectionend' ),
- 'woocommerce_shop_page_id' => 'single_select_page',
+ 'woocommerce_shop_page_id' => 'single_select_page_with_search',
'woocommerce_cart_redirect_after_add' => 'checkbox',
'woocommerce_enable_ajax_add_to_cart' => 'checkbox',
'woocommerce_placeholder_image' => 'text',