Commit db51e985b88 for woocommerce

commit db51e985b88791ca406026f6497a124ab0cb9343
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date:   Thu Jul 23 00:05:17 2026 +0100

    Preserve navigation during Settings UI fallbacks (#66668)

    * Preserve navigation during Settings UI fallbacks

    * Add changelog entry for Settings UI fallback navigation

    * fix(settings): Move Settings UI context reads inside the update guard

    The template resolved SettingsUIRequestContext inside a try/catch added
    for the mid-update window, but the drill-down predicate and settings
    page lookup still called context methods outside it. A stale cached
    class without the newer methods (is_drill_down() is 11.1) would fatal
    with an undefined method error instead of falling back.

    Compute the predicate and page lookup inside the guard so a stale class
    degrades to the classic settings view. Raised by CodeRabbit.

    Refs #66664

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * refactor(tests): Share the settings view render harness

    The two settings view tests each carried the same ~30 lines of setup:
    admin user, request globals, page replacement, hook swaps, and template
    include with restore. Extract render_settings_view_for_checkout_section
    so view tests state only their fixture and assertions.

    Refs #66664

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * test(settings): Cover tab hiding on successful drill-downs

    The fallback tests assert the classic navigation stays, but nothing
    pinned the success path: a drill-down whose shell renders should hide
    the top-level tabs. Add a view test asserting the tabs are absent when
    the Settings UI mount point renders.

    Refs #66664

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * fix(settings): Guard body class context reads during updates

    add_settings_ui_body_class called is_drill_down(), has_schema_failed()
    and has_script_handles_failed() on the request context with no guard,
    inside the admin_body_class filter. Mid-update, a stale cached
    SettingsUIRequestContext (10.9.x lacks is_drill_down()) would throw and
    fatal the settings page for sites with the settings-ui flag enabled.

    Wrap the context reads in try/catch and return the classes unchanged on
    failure: a missing body class renders the legacy header, which is the
    safe fallback. Same pattern as the template guard on PR #66668.

    Refs WOOPRD-3585

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * test(settings): Close leaked output buffers in the render harness

    If the included settings view throws, ob_get_clean() never runs and the
    buffer opened by the helper leaks into subsequent tests. Track the
    buffer level and drain back to it in the finally block; the success
    path is unchanged since ob_get_clean() already closed the buffer.
    Raised by CodeRabbit.

    Refs #66664

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * docs(settings): Add mid-update guard rules for agents

    The mid-update window pairs new files with stale cached classes, and
    the settings surface renders wp-admin, so unguarded calls there can
    white-screen the settings page. Record the guard invariant, the 10.9
    method floor, and the additive-only rules next to the classes they
    protect so future changes keep them.

    Refs #66664

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/66664-fix-settings-ui-fallback-navigation b/plugins/woocommerce/changelog/66664-fix-settings-ui-fallback-navigation
new file mode 100644
index 00000000000..dd544866e32
--- /dev/null
+++ b/plugins/woocommerce/changelog/66664-fix-settings-ui-fallback-navigation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve settings navigation when Payments sections fall back from the Settings UI.
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php
index 65127abe6f1..5573e5b6d7d 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php
@@ -141,7 +141,15 @@ if ( ! class_exists( 'WC_Settings_Page', false ) ) :
 			$section = is_string( $current_section ) ? $current_section : '';
 			$context = $this->get_settings_ui_request_context( $section );

-			if ( ! $context || ! $context->is_rendering_enabled() ) {
+			try {
+				if ( ! $context || ! $context->is_rendering_enabled() ) {
+					return $classes;
+				}
+
+				$is_rendering_drill_down = $context->is_drill_down()
+					&& ! $context->has_schema_failed()
+					&& ! $context->has_script_handles_failed();
+			} catch ( \Throwable $e ) {
 				return $classes;
 			}

@@ -153,9 +161,7 @@ if ( ! class_exists( 'WC_Settings_Page', false ) ) :
 			}

 			if (
-				$context->is_drill_down()
-				&& ! $context->has_schema_failed()
-				&& ! $context->has_script_handles_failed()
+				$is_rendering_drill_down
 				&& ! in_array( 'woocommerce-settings-ui-drill-down', $body_classes, true )
 			) {
 				$classes .= ' woocommerce-settings-ui-drill-down';
diff --git a/plugins/woocommerce/includes/admin/views/html-admin-settings.php b/plugins/woocommerce/includes/admin/views/html-admin-settings.php
index 84e9fb1c7c4..8352756fdb8 100644
--- a/plugins/woocommerce/includes/admin/views/html-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/views/html-admin-settings.php
@@ -37,28 +37,38 @@ if ( ! $tab_exists ) {
 }

 // Resolve the Settings UI context for this request, falling back to legacy
-// rendering when the settings SDK classes are unavailable. The class can be
-// missing mid-update, when this file has been replaced on disk but the cached
-// autoloader has not refreshed yet.
-$settings_ui_context = null;
+// rendering when the settings SDK classes are unavailable or stale. The class
+// can be missing or outdated mid-update, when this file has been replaced on
+// disk but the cached autoloader has not refreshed yet.
+$settings_ui_context                 = null;
+$settings_ui_settings_page           = null;
+$is_rendering_settings_ui_drill_down = false;
 try {
 	if ( class_exists( SettingsUIRequestContext::class ) ) {
 		$settings_ui_context = SettingsUIRequestContext::get_current();
 	}
+
+	if ( $settings_ui_context ) {
+		$settings_ui_settings_page           = $settings_ui_context->get_settings_page();
+		$is_rendering_settings_ui_drill_down = $settings_ui_context->is_rendering_enabled()
+			&& $settings_ui_context->is_drill_down()
+			&& ! $settings_ui_context->has_schema_failed()
+			&& ! $settings_ui_context->has_script_handles_failed();
+	}
 } catch ( \Throwable $e ) {
-	$settings_ui_context = null;
+	$settings_ui_context                 = null;
+	$settings_ui_settings_page           = null;
+	$is_rendering_settings_ui_drill_down = false;
 }

-// Drill-down pages replace the top-level settings tabs with their own header.
+// Drill-down pages replace the top-level settings tabs with their own header when the shell can render.
 $hide_nav = ( 'checkout' === $current_tab && in_array( $current_section, array( 'offline', 'bacs', 'cheque', 'cod' ), true ) )
-	|| ( $settings_ui_context && $settings_ui_context->is_drill_down() );
+	|| $is_rendering_settings_ui_drill_down;

-$settings_ui_settings_page = $settings_ui_context ? $settings_ui_context->get_settings_page() : null;
-$is_settings_ui_page       = null !== $settings_ui_settings_page;
+$is_settings_ui_page = null !== $settings_ui_settings_page;

-// Drill-down pages replace the section links with header breadcrumbs. Top-level
-// pages keep the classic section links.
-if ( $settings_ui_settings_page instanceof WC_Settings_Page && $settings_ui_context->is_drill_down() ) {
+// Drill-down pages replace the section links with header breadcrumbs when the shell can render.
+if ( $settings_ui_settings_page instanceof WC_Settings_Page && $is_rendering_settings_ui_drill_down ) {
 	remove_action( 'woocommerce_sections_' . $current_tab, array( $settings_ui_settings_page, 'output_sections' ) );
 }

diff --git a/plugins/woocommerce/src/Admin/Settings/AGENTS.md b/plugins/woocommerce/src/Admin/Settings/AGENTS.md
new file mode 100644
index 00000000000..11636d56587
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Settings/AGENTS.md
@@ -0,0 +1,16 @@
+# Settings UI
+
+These classes power the Settings UI renderer for classic `wc-settings` pages. They are autoloaded modern code, but they are called from legacy code (`includes/admin/settings/`, `includes/admin/views/html-admin-settings.php`) that runs on every settings request.
+
+## Mid-update fatal guard (required for every change)
+
+During a plugin update, a request can pair new files on disk with stale cached classes (or the reverse). The settings surface renders wp-admin, so one unguarded call in that window white-screens the settings page. The 10.9.1 release was reverted on WP Cloud for exactly this bug class.
+
+Rules for this package and its call sites:
+
+- Code reachable from `includes/`, templates, or hook callbacks must call `SettingsUIRequestContext` (and the other classes here) either through methods that exist since WooCommerce 10.9, or inside a `class_exists` check plus `try/catch (\Throwable)` that falls back to legacy rendering. 10.9.x is the oldest release shipping these classes, so it is the version a stale class can be. `is_drill_down()` and `get_settings_page()` do not exist in 10.9; the rest of the context's surface does.
+- Public and protected method signatures are additive only. Never remove or change one; deprecate and keep it working.
+- Never delete or rename a class file that has shipped in a release. A stale classmap entry pointing at a missing file fatals inside the autoloader, where no guard can catch it.
+- Never add a required method to an interface here. Stale implementers fail at class-link time, uncatchably. Add a concrete default to the `SettingsSection` base class instead, the way `SettingsSectionUIPageProviderInterface` was introduced.
+
+Guarded call sites to copy from: `includes/admin/views/html-admin-settings.php`, `WC_Settings_Page::add_settings_ui_body_class()`, `Settings::add_settings_ui_schema()`, `WCAdminAssets::get_settings_ui_script_dependencies()`.
diff --git a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
index 05e2142c553..72f506bd620 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
@@ -358,38 +358,55 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
 		add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
 		SettingsSectionRegistry::get_instance()->register( $this->get_registered_section_with_native_settings_ui_page() );

-		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		$output = $this->render_settings_view_for_checkout_section( 'acme_payments' );

-		global $current_section, $current_tab;
-		$current_tab     = 'checkout';
-		$current_section = 'acme_payments';
-		$_GET['page']    = 'wc-settings';
-		$_GET['tab']     = 'checkout';
-		$_GET['section'] = 'acme_payments';
-		// PHP builds $_REQUEST once at request start, so runtime $_GET changes need mirroring.
-		$_REQUEST['section'] = 'acme_payments';
+		$this->assertStringContainsString( 'data-wc-settings-page="acme_native"', $output );
+		$this->assertStringNotContainsString( 'class="subsubsub"', $output );
+	}

-		$page                   = $this->get_parent_page();
-		$original_settings      = $this->replace_wc_admin_settings_pages( array( $page ) );
-		$tabs                   = array( 'checkout' => 'Payments' );
-		$original_sections_hook = $this->replace_hook_callbacks( 'woocommerce_sections_checkout' );
-		$original_settings_hook = $this->replace_hook_callbacks( 'woocommerce_settings_checkout' );
+	/**
+	 * @testdox Should hide the top-level tabs for drill-down Settings UI pages.
+	 */
+	public function test_hides_top_level_tabs_for_drill_down_settings_ui_pages(): void {
+		add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+		SettingsSectionRegistry::get_instance()->register( $this->get_registered_section_with_native_settings_ui_page() );

-		add_action( 'woocommerce_sections_checkout', array( $page, 'output_sections' ) );
-		add_action( 'woocommerce_settings_checkout', array( $page, 'output' ) );
-
-		try {
-			ob_start();
-			include WC_ABSPATH . 'includes/admin/views/html-admin-settings.php';
-			$output = ob_get_clean();
-		} finally {
-			$this->restore_hook_callbacks( 'woocommerce_sections_checkout', $original_sections_hook );
-			$this->restore_hook_callbacks( 'woocommerce_settings_checkout', $original_settings_hook );
-			$this->replace_wc_admin_settings_pages( $original_settings );
-		}
+		$output = $this->render_settings_view_for_checkout_section( 'acme_payments' );

 		$this->assertStringContainsString( 'data-wc-settings-page="acme_native"', $output );
-		$this->assertStringNotContainsString( 'class="subsubsub"', $output );
+		$this->assertStringNotContainsString( 'nav-tab-wrapper', $output, 'Drill-down pages replace the top-level tabs with the shell header.' );
+	}
+
+	/**
+	 * @testdox Should preserve classic navigation when a registered drill-down falls back.
+	 *
+	 * @dataProvider settings_ui_failure_stages
+	 *
+	 * @param string $failure_stage Settings UI resolution stage that should fail.
+	 */
+	public function test_preserves_classic_navigation_when_registered_drill_down_falls_back( string $failure_stage ): void {
+		add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+		SettingsSectionRegistry::get_instance()->register( $this->get_registered_section_with_native_settings_ui_page( null, null, $failure_stage ) );
+		$this->setExpectedIncorrectUsage( 'WC_Settings_Page::output' );
+
+		$output = $this->render_settings_view_for_checkout_section( 'acme_payments' );
+
+		$this->assertStringContainsString( 'woo-nav-tab-wrapper', $output, 'The top-level settings tabs should remain available.' );
+		$this->assertStringContainsString( 'class="subsubsub"', $output, 'The classic section links should remain available.' );
+		$this->assertStringContainsString( 'name="registered_acme_payments_setting"', $output, 'The legacy settings fields should render.' );
+		$this->assertStringNotContainsString( 'data-wc-settings-ui="1"', $output, 'The Settings UI mount point should not render.' );
+	}
+
+	/**
+	 * Settings UI failure stages.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public static function settings_ui_failure_stages(): array {
+		return array(
+			'schema resolution'        => array( 'schema' ),
+			'script handle resolution' => array( 'script_handles' ),
+		);
 	}

 	/**
@@ -564,10 +581,11 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
 	 *
 	 * @param callable|null $on_settings_ui_page_call Callback invoked every time the Settings UI page provider runs.
 	 * @param array|null    $shell Schema shell for the native page. Null uses the fixture default with custom section navigation.
+	 * @param string|null   $failure_stage Settings UI resolution stage that should fail.
 	 * @return SettingsSectionInterface
 	 */
-	private function get_registered_section_with_native_settings_ui_page( ?callable $on_settings_ui_page_call = null, ?array $shell = null ): SettingsSectionInterface {
-		return new class( $on_settings_ui_page_call, $shell ) extends SettingsSection {
+	private function get_registered_section_with_native_settings_ui_page( ?callable $on_settings_ui_page_call = null, ?array $shell = null, ?string $failure_stage = null ): SettingsSectionInterface {
+		return new class( $on_settings_ui_page_call, $shell, $failure_stage ) extends SettingsSection {
 			/**
 			 * Callback invoked every time the Settings UI page provider runs.
 			 *
@@ -582,15 +600,24 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
 			 */
 			private ?array $shell;

+			/**
+			 * Settings UI resolution stage that should fail.
+			 *
+			 * @var string|null
+			 */
+			private ?string $failure_stage;
+
 			/**
 			 * Constructor.
 			 *
 			 * @param callable|null $on_settings_ui_page_call Callback invoked every time the Settings UI page provider runs.
 			 * @param array|null    $shell Schema shell for the native page, or null for the fixture default.
+			 * @param string|null   $failure_stage Settings UI resolution stage that should fail.
 			 */
-			public function __construct( ?callable $on_settings_ui_page_call, ?array $shell ) {
+			public function __construct( ?callable $on_settings_ui_page_call, ?array $shell, ?string $failure_stage ) {
 				$this->on_settings_ui_page_call = $on_settings_ui_page_call;
 				$this->shell                    = $shell;
+				$this->failure_stage            = $failure_stage;
 			}

 			/**
@@ -647,7 +674,7 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
 					( $this->on_settings_ui_page_call )();
 				}

-				return new class( $this->shell ) implements SettingsUIPageInterface {
+				return new class( $this->shell, $this->failure_stage ) implements SettingsUIPageInterface {
 					/**
 					 * Schema shell, or null for the fixture default.
 					 *
@@ -655,13 +682,22 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
 					 */
 					private ?array $shell;

+					/**
+					 * Settings UI resolution stage that should fail.
+					 *
+					 * @var string|null
+					 */
+					private ?string $failure_stage;
+
 					/**
 					 * Constructor.
 					 *
-					 * @param array|null $shell Schema shell, or null for the fixture default.
+					 * @param array|null  $shell Schema shell, or null for the fixture default.
+					 * @param string|null $failure_stage Settings UI resolution stage that should fail.
 					 */
-					public function __construct( ?array $shell ) {
-						$this->shell = $shell;
+					public function __construct( ?array $shell, ?string $failure_stage ) {
+						$this->shell         = $shell;
+						$this->failure_stage = $failure_stage;
 					}

 					/**
@@ -680,6 +716,10 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
 					 * @return array
 					 */
 					public function get_schema( string $section ): array {
+						if ( 'schema' === $this->failure_stage ) {
+							throw new \RuntimeException( 'Unable to resolve the Settings UI schema.' );
+						}
+
 						return array(
 							'id'      => 'acme_native',
 							'title'   => 'Acme native settings',
@@ -716,6 +756,10 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
 					 * @return string[]
 					 */
 					public function get_script_handles( string $section ): array {
+						if ( 'script_handles' === $this->failure_stage ) {
+							throw new \RuntimeException( 'Unable to resolve Settings UI script handles.' );
+						}
+
 						return array( 'acme-native-settings-ui' );
 					}

@@ -805,6 +849,53 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
 		};
 	}

+	/**
+	 * Render the full settings view for a checkout section as an administrator.
+	 *
+	 * @param string $section Section id.
+	 * @return string Rendered settings view output.
+	 */
+	private function render_settings_view_for_checkout_section( string $section ): string {
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+
+		global $current_section, $current_tab;
+		$current_tab     = 'checkout';
+		$current_section = $section;
+		$_GET['page']    = 'wc-settings';
+		$_GET['tab']     = 'checkout';
+		$_GET['section'] = $section;
+		// PHP builds $_REQUEST once at request start, so runtime $_GET changes need mirroring.
+		$_REQUEST['section'] = $section;
+
+		$page                   = $this->get_parent_page();
+		$original_settings      = $this->replace_wc_admin_settings_pages( array( $page ) );
+		$tabs                   = array(
+			'general'  => 'General',
+			'checkout' => 'Payments',
+		);
+		$original_sections_hook = $this->replace_hook_callbacks( 'woocommerce_sections_checkout' );
+		$original_settings_hook = $this->replace_hook_callbacks( 'woocommerce_settings_checkout' );
+
+		add_action( 'woocommerce_sections_checkout', array( $page, 'output_sections' ) );
+		add_action( 'woocommerce_settings_checkout', array( $page, 'output' ) );
+
+		$buffer_level = ob_get_level();
+		ob_start();
+
+		try {
+			include WC_ABSPATH . 'includes/admin/views/html-admin-settings.php';
+			return ob_get_clean();
+		} finally {
+			// Drain buffers left open when rendering throws.
+			while ( ob_get_level() > $buffer_level ) {
+				ob_end_clean();
+			}
+			$this->restore_hook_callbacks( 'woocommerce_sections_checkout', $original_sections_hook );
+			$this->restore_hook_callbacks( 'woocommerce_settings_checkout', $original_settings_hook );
+			$this->replace_wc_admin_settings_pages( $original_settings );
+		}
+	}
+
 	/**
 	 * Replace callbacks for a hook.
 	 *