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