Commit 3685b0ac56a for woocommerce
commit 3685b0ac56a57bb00779c72d4870bb83a2e6225d
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date: Mon Aug 10 11:14:33 2026 +0300
Honour the position argument of wc_admin_register_page() for sub pages (#66826)
* Honour the position argument of wc_admin_register_page() for sub pages
register_page() passed `position` to add_menu_page() for top level pages
but dropped it when calling add_submenu_page(), so extensions could not
control where their React-powered pages appear within a parent submenu.
The docs were narrowed to say `position` was for parent pages only, but
the underlying limitation remained.
Pass `position` through as the seventh argument of add_submenu_page().
It has been supported since WP 5.3 and WooCommerce requires WP 6.9, so
the semantics now match WordPress exactly: null appends, an out of range
value appends, and 0 prepends.
The default stays null and no page registered by core passes a position
for a sub page, so existing menus are unchanged.
Closes #35216
* Restore all state mutated by the menu fixture in PageControllerTest
diff --git a/docs/extensions/settings-and-config/working-with-woocommerce-admin-pages.md b/docs/extensions/settings-and-config/working-with-woocommerce-admin-pages.md
index f23a62fa44d..f6f022d4bab 100644
--- a/docs/extensions/settings-and-config/working-with-woocommerce-admin-pages.md
+++ b/docs/extensions/settings-and-config/working-with-woocommerce-admin-pages.md
@@ -61,7 +61,7 @@ To register a React-powered page, use the [`wc_admin_register_page()`](https://w
- `path` (**required**) - This is the page's path (relative to `#wc-admin`). It is used for identifying this page and for linking breadcrumb pieces when this page is a parent.
- `capability` (_optional_) - User capability needed to access this page. The default value is `manage_options`.
- `icon` (_optional_) - Use this to apply a Dashicons helper class or base64-encoded SVG. Include the entire dashicon class name, ie `dashicons-*`. Note that this won't be included in WooCommerce Admin Navigation.
-- `position` (_optional_) - Menu item position for parent pages. See: [`add_menu_page()`](https://developer.wordpress.org/reference/functions/add_menu_page/).
+- `position` (_optional_) - Menu item position. See: [`add_menu_page()`](https://developer.wordpress.org/reference/functions/add_menu_page/) and [`add_submenu_page()`](https://developer.wordpress.org/reference/functions/add_submenu_page/).
Registering a React-powered page is similar to connecting a PHP page, but with some key differences. Registering pages will automatically create WordPress menu items for them, with the appropriate hierarchy based on the value of `parent`.
diff --git a/plugins/woocommerce/changelog/35216-fix-register-page-submenu-position b/plugins/woocommerce/changelog/35216-fix-register-page-submenu-position
new file mode 100644
index 00000000000..10da7d2ddd1
--- /dev/null
+++ b/plugins/woocommerce/changelog/35216-fix-register-page-submenu-position
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Honour the position argument of wc_admin_register_page() for sub pages, so extensions can control where their React-powered pages appear within a parent submenu. Previously position was only passed to add_menu_page() for top level pages and silently ignored for sub pages.
diff --git a/plugins/woocommerce/src/Admin/PageController.php b/plugins/woocommerce/src/Admin/PageController.php
index 9b86c112920..47db11fbe09 100644
--- a/plugins/woocommerce/src/Admin/PageController.php
+++ b/plugins/woocommerce/src/Admin/PageController.php
@@ -495,7 +495,8 @@ class PageController {
$options['title'],
$options['capability'],
$options['path'],
- array( __CLASS__, 'page_wrapper' )
+ array( __CLASS__, 'page_wrapper' ),
+ $options['position']
);
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/PageControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/PageControllerTest.php
index c422c94d782..ff2fd8b5d26 100644
--- a/plugins/woocommerce/tests/php/src/Admin/PageControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/PageControllerTest.php
@@ -13,6 +13,13 @@ use WC_Unit_Test_Case;
* @covers \Automattic\WooCommerce\Admin\PageController
*/
class PageControllerTest extends WC_Unit_Test_Case {
+ /**
+ * Admin menu globals mutated by register_page() via add_menu_page()/add_submenu_page().
+ *
+ * @var string[]
+ */
+ private const MENU_FIXTURE_GLOBALS = array( 'menu', 'submenu', 'admin_page_hooks', '_registered_pages', '_parent_pages' );
+
/**
* PageController instance.
*
@@ -55,6 +62,14 @@ class PageControllerTest extends WC_Unit_Test_Case {
*/
private $redirected_to = '';
+ /**
+ * Snapshot of the state mutated by the menu fixture, set when one is registered.
+ * Holds the admin menu globals (MENU_FIXTURE_GLOBALS) and the PageController pages collection.
+ *
+ * @var array|null
+ */
+ private $menu_fixture_backup = null;
+
/**
* Set things up before each test case.
*
@@ -104,6 +119,24 @@ class PageControllerTest extends WC_Unit_Test_Case {
$GLOBALS['current_screen'] = $this->current_screen_backup; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
+ // Restore the state mutated by the menu fixture, if one was registered.
+ if ( null !== $this->menu_fixture_backup ) {
+ foreach ( self::MENU_FIXTURE_GLOBALS as $global_name ) {
+ if ( array_key_exists( $global_name, $this->menu_fixture_backup['globals'] ) ) {
+ $GLOBALS[ $global_name ] = $this->menu_fixture_backup['globals'][ $global_name ]; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the pre-fixture values.
+ } else {
+ unset( $GLOBALS[ $global_name ] );
+ }
+ }
+
+ // Restore the private pages collection of the PageController singleton.
+ $pages_property = new \ReflectionProperty( PageController::class, 'pages' );
+ $pages_property->setAccessible( true );
+ $pages_property->setValue( $this->sut, $this->menu_fixture_backup['pages'] );
+
+ $this->menu_fixture_backup = null;
+ }
+
parent::tearDown();
}
@@ -532,6 +565,147 @@ class PageControllerTest extends WC_Unit_Test_Case {
$this->assertEquals( 'WCADMIN_PAYMENT_TASK', $params['from'], 'Redirect should include from parameter.' );
}
+ /**
+ * @testdox Should append sub pages in registration order when no position is given.
+ */
+ public function test_register_page_appends_sub_pages_without_position(): void {
+ wp_set_current_user( $this->admin_user_id );
+
+ $parent_path = $this->register_menu_fixture(
+ array(
+ array( 'id' => 'position-test-first' ),
+ array( 'id' => 'position-test-second' ),
+ array( 'id' => 'position-test-third' ),
+ )
+ );
+
+ $this->assertSame(
+ array( 'position-test-parent', 'position-test-first', 'position-test-second', 'position-test-third' ),
+ $this->get_submenu_titles( $parent_path ),
+ 'Sub pages without a position should keep their registration order.'
+ );
+ }
+
+ /**
+ * @testdox Should place a sub page at the given position within the parent submenu.
+ */
+ public function test_register_page_honours_position_for_sub_pages(): void {
+ wp_set_current_user( $this->admin_user_id );
+
+ // Position 2 accounts for the link back to the parent that WordPress adds as the first submenu item.
+ $parent_path = $this->register_menu_fixture(
+ array(
+ array( 'id' => 'position-test-first' ),
+ array( 'id' => 'position-test-third' ),
+ array(
+ 'id' => 'position-test-second',
+ 'position' => 2,
+ ),
+ )
+ );
+
+ $this->assertSame(
+ array( 'position-test-parent', 'position-test-first', 'position-test-second', 'position-test-third' ),
+ $this->get_submenu_titles( $parent_path ),
+ 'A sub page registered with position 2 should be inserted as the third item.'
+ );
+ }
+
+ /**
+ * @testdox Should append a sub page whose position is beyond the end of the parent submenu.
+ */
+ public function test_register_page_appends_sub_page_with_out_of_range_position(): void {
+ wp_set_current_user( $this->admin_user_id );
+
+ $parent_path = $this->register_menu_fixture(
+ array(
+ array( 'id' => 'position-test-first' ),
+ array( 'id' => 'position-test-second' ),
+ array(
+ 'id' => 'position-test-third',
+ 'position' => 99,
+ ),
+ )
+ );
+
+ $this->assertSame(
+ array( 'position-test-parent', 'position-test-first', 'position-test-second', 'position-test-third' ),
+ $this->get_submenu_titles( $parent_path ),
+ 'An out of range position should append the sub page instead of dropping it.'
+ );
+ }
+
+ /**
+ * Registers a top level page plus the given sub pages, and returns the parent menu slug.
+ *
+ * The global admin menu is emptied first so that assertions only see the fixture. All the state
+ * mutated by the registration (the admin menu globals and the PageController pages collection)
+ * is snapshotted here and restored in tear down.
+ *
+ * @param array $sub_pages List of option arrays passed to register_page(), each with at least an `id`.
+ *
+ * @return string The parent menu slug to look up in $submenu.
+ */
+ private function register_menu_fixture( array $sub_pages ): string {
+ global $menu, $submenu;
+
+ $backup_globals = array();
+ foreach ( self::MENU_FIXTURE_GLOBALS as $global_name ) {
+ if ( isset( $GLOBALS[ $global_name ] ) ) {
+ $backup_globals[ $global_name ] = $GLOBALS[ $global_name ];
+ }
+ }
+
+ $this->menu_fixture_backup = array(
+ 'globals' => $backup_globals,
+ 'pages' => $this->sut->get_pages(),
+ );
+
+ $menu = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $submenu = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ $this->sut->register_page(
+ array(
+ 'id' => 'position-test-parent',
+ 'title' => 'position-test-parent',
+ 'parent' => null,
+ 'path' => '/position-test-parent',
+ 'capability' => 'manage_woocommerce',
+ )
+ );
+
+ foreach ( $sub_pages as $sub_page ) {
+ $this->sut->register_page(
+ array_merge(
+ array(
+ 'title' => $sub_page['id'],
+ 'parent' => 'position-test-parent',
+ 'path' => '/' . $sub_page['id'],
+ 'capability' => 'manage_woocommerce',
+ ),
+ $sub_page
+ )
+ );
+ }
+
+ return $this->sut->get_path_from_id( 'position-test-parent' );
+ }
+
+ /**
+ * Returns the menu titles of the sub pages registered under the given parent, in menu order.
+ *
+ * @param string $parent_path The parent menu slug.
+ *
+ * @return array
+ */
+ private function get_submenu_titles( string $parent_path ): array {
+ global $submenu;
+
+ $items = $submenu[ $parent_path ] ?? array();
+
+ return array_values( wp_list_pluck( $items, 0 ) );
+ }
+
/**
* Returns an object mocking what we need from \WP_Screen.
*