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&hellip;', '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&section=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&section=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&section=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',