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.
*