Commit bad5ffc1f6d for woocommerce

commit bad5ffc1f6d2fa8a7ccce32120dc6601ae654266
Author: louwie17 <lourensschep@gmail.com>
Date:   Tue Oct 6 14:56:36 2026 +0200

    Hide the legacy Reports menu in the Calypso sidebar on new stores (#69367)

    * Hide the legacy Reports menu on a late admin_menu too

    The Calypso sidebar's /wpcom/v2/admin-menu endpoint builds the menu
    through admin_menu only, so the admin_head pass never reached it. Hide
    the item at the end of admin_menu as well, treat a pending
    woocommerce_newly_installed flag as a new store before admin_init
    records the initial version, and let admin_head undo the early hide when
    a late extension adds legacy reports.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01HnzJDr656CzZ2BBSNV5quY

    * Find hidden Reports items by slug when showing them again

    WordPress can renumber $menu and add classes such as menu-top-last
    between admin_menu and admin_head. Restoring by saved index and class
    snapshot left the top-level item hidden after a reorder and dropped
    those classes. Find the items by slug and remove only the hide class
    this class added.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01HnzJDr656CzZ2BBSNV5quY

    * Remember a found extension report for the rest of the request

    The menu decision now runs on admin_menu and admin_head, so on new
    stores with a reporting extension the legacy reports were built twice
    per admin page. Keep a positive result; a negative one is still
    re-checked so admin_head catches filters registered later.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01HnzJDr656CzZ2BBSNV5quY

    * Store a found extension report until a plugin is deactivated

    Some extensions' legacy report filters are expensive; Box Office checks
    whether the HPOS tables are in sync with an uncached query. Now that the
    filters run on every admin page and the Calypso menu request, store
    'yes' in woocommerce_store_has_legacy_reports_related_plugins once an
    extension report is found and skip get_reports() after that. Deactivating
    any plugin resets it to 'no', which is checked again on every request.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01HnzJDr656CzZ2BBSNV5quY

    * Decide on the legacy Reports menu once, on admin_menu

    With the admin_menu pass and the newly installed fallback, admin_head
    only differed for filters registered after admin_menu, which none of the
    audited extensions do, and it let wp-admin and the Calypso sidebar
    disagree. Drop the admin_head pass and its restore logic, and guard the
    deactivation reset against a class missing after a plugin file swap.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01HnzJDr656CzZ2BBSNV5quY

    * Drop the stored legacy reports check

    Core's wc_admin_connect_core_pages() already calls
    WC_Admin_Reports::get_reports() 14-18 times on every wp-admin page, so
    storing our single call's result saved almost nothing in wp-admin. Remove
    the woocommerce_store_has_legacy_reports_related_plugins option and its
    deactivated_plugin reset.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01HnzJDr656CzZ2BBSNV5quY

    * Rely on the WC_Admin_Menus constructor hooks in the registration test

    The constructor already registers the admin_menu callbacks, so adding
    them again in the test was a no-op.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01HnzJDr656CzZ2BBSNV5quY

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/wooplug-7853-hide-legacy-reports-menu-on-admin-menu b/plugins/woocommerce/changelog/wooplug-7853-hide-legacy-reports-menu-on-admin-menu
new file mode 100644
index 00000000000..5684847122e
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7853-hide-legacy-reports-menu-on-admin-menu
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Hide the legacy Reports menu in the WordPress.com sidebar on new stores, matching wp-admin.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
index 1a47d24707e..fae388bac96 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
@@ -114,7 +114,7 @@ class WC_Admin_Menus {
 			add_menu_page( __( 'Sales reports', 'woocommerce' ), __( 'Sales reports', 'woocommerce' ), 'view_woocommerce_reports', 'wc-reports', array( $this, 'reports_page' ), 'dashicons-chart-bar', '55.6' );
 		}

-		add_action( 'admin_head', array( wc_get_container()->get( LegacyReportsMenu::class ), 'handle_admin_head' ), PHP_INT_MAX );
+		add_action( 'admin_menu', array( wc_get_container()->get( LegacyReportsMenu::class ), 'handle_admin_menu' ), PHP_INT_MAX );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Admin/LegacyReportsMenu.php b/plugins/woocommerce/src/Internal/Admin/LegacyReportsMenu.php
index 024ef197517..e5c643feeab 100644
--- a/plugins/woocommerce/src/Internal/Admin/LegacyReportsMenu.php
+++ b/plugins/woocommerce/src/Internal/Admin/LegacyReportsMenu.php
@@ -39,21 +39,14 @@ final class LegacyReportsMenu {
 	private const CORE_CALLBACK = array( 'WC_Admin_Reports', 'get_report' );

 	/**
-	 * Result of should_show(), cached for the request.
+	 * Hide the Reports menu item at the end of admin_menu.
 	 *
-	 * @var bool|null
-	 */
-	private ?bool $should_show = null;
-
-	/**
-	 * Hide the Reports menu item right before the admin menu is printed.
-	 *
-	 * Runs this late so that the initial installed version has been recorded and extensions
-	 * have had every chance to register their report filters.
+	 * Runs on admin_menu rather than admin_head, since requests that only build the menu, such as the Calypso
+	 * sidebar's /wpcom/v2/admin-menu endpoint, never reach admin_head.
 	 *
 	 * @internal
 	 */
-	public function handle_admin_head(): void {
+	public function handle_admin_menu(): void {
 		if ( ! current_user_can( 'view_woocommerce_reports' ) || $this->should_show() ) {
 			return;
 		}
@@ -85,10 +78,6 @@ final class LegacyReportsMenu {
 	 * @return bool
 	 */
 	private function should_show(): bool {
-		if ( null !== $this->should_show ) {
-			return $this->should_show;
-		}
-
 		$show = ! $this->is_new_store()
 			|| ! FeaturesUtil::feature_is_enabled( 'analytics' )
 			|| $this->has_extension_reports();
@@ -98,7 +87,7 @@ final class LegacyReportsMenu {
 		 *
 		 * The page stays reachable at admin.php?page=wc-reports either way. By default the item is hidden on
 		 * stores first installed on WooCommerce 11.3.0 or later, unless Analytics is disabled or an extension
-		 * adds legacy reports.
+		 * adds legacy reports. Runs at the end of admin_menu, so add callbacks before then.
 		 *
 		 * @since 11.3.0
 		 *
@@ -106,22 +95,26 @@ final class LegacyReportsMenu {
 		 */
 		$filtered = apply_filters( 'woocommerce_show_legacy_reports_menu', $show );

-		$filtered          = is_scalar( $filtered ) ? filter_var( $filtered, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) : null;
-		$this->should_show = $filtered ?? $show;
+		$filtered = is_scalar( $filtered ) ? filter_var( $filtered, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) : null;

-		return $this->should_show;
+		return $filtered ?? $show;
 	}

 	/**
 	 * Whether the store was first installed on the version that hides the menu, or later.
 	 *
-	 * A missing or malformed initial version (e.g. stores installed before 9.2.0) counts as an existing store.
+	 * Until admin_init records the initial version on a fresh install, the current version is used. A missing
+	 * or malformed initial version (e.g. stores installed before 9.2.0) counts as an existing store.
 	 *
 	 * @return bool
 	 */
 	private function is_new_store(): bool {
 		$initial_version = get_option( WC_Install::INITIAL_INSTALLED_VERSION );

+		if ( false === $initial_version && 'yes' === get_option( WC_Install::NEWLY_INSTALLED_OPTION ) ) {
+			$initial_version = WC()->version;
+		}
+
 		if ( ! is_string( $initial_version ) || ! preg_match( '/^\d+\.\d+\.\d+(-(dev|alpha|beta|rc)(\.?\d+)?)?$/i', $initial_version ) ) {
 			return false;
 		}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/LegacyReportsMenuTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/LegacyReportsMenuTest.php
index 15a0e164408..f4741f7aefb 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/LegacyReportsMenuTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/LegacyReportsMenuTest.php
@@ -48,6 +48,7 @@ class LegacyReportsMenuTest extends WC_Unit_Test_Case {

 		update_option( 'woocommerce_analytics_enabled', 'yes' );
 		update_option( WC_Install::INITIAL_INSTALLED_VERSION, '11.3.0' );
+		update_option( WC_Install::NEWLY_INSTALLED_OPTION, 'no' );
 		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );

 		$this->sut = new LegacyReportsMenu();
@@ -61,7 +62,7 @@ class LegacyReportsMenuTest extends WC_Unit_Test_Case {
 			foreach ( $this->globals_backup as $name => $value ) {
 				$GLOBALS[ $name ] = $value; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
 			}
-			// The container's LegacyReportsMenu caches its decision for the request.
+			// The container's LegacyReportsMenu remembers which items it hid.
 			$this->reset_container_resolutions();
 		} finally {
 			parent::tearDown();
@@ -353,27 +354,51 @@ class LegacyReportsMenuTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should register the container instance on admin_head from reports_menu() so production hides the item.
+	 * @testdox Should treat a fresh install as new before admin_init records the initial installed version.
+	 *
+	 * @testWith ["yes", false]
+	 *           ["no", true]
+	 *           [null, true]
+	 *
+	 * @param string|null $newly_installed The woocommerce_newly_installed option value, or null when missing.
+	 * @param bool        $expected_show   Whether the menu is expected to be shown.
 	 */
-	public function test_reports_menu_registers_admin_head_handler(): void {
-		$this->reset_container_resolutions();
+	public function test_new_store_detection_before_version_is_recorded( ?string $newly_installed, bool $expected_show ): void {
+		delete_option( WC_Install::INITIAL_INSTALLED_VERSION );
+		if ( null === $newly_installed ) {
+			delete_option( WC_Install::NEWLY_INSTALLED_OPTION );
+		} else {
+			update_option( WC_Install::NEWLY_INSTALLED_OPTION, $newly_installed );
+		}
+
 		$this->register_woocommerce_menu();
+		$this->sut->handle_admin_menu();
+
+		$this->assertSame( $expected_show, ! $this->has_hide_class( 'submenu', 'wc-reports' ) );
+	}

-		$handler = array( wc_get_container()->get( LegacyReportsMenu::class ), 'handle_admin_head' );
-		$this->assertSame( PHP_INT_MAX, has_action( 'admin_head', $handler ), 'reports_menu() must hook the handler late on admin_head' );
+	/**
+	 * @testdox Should register the container instance on a late admin_menu from reports_menu(), so requests that only build the menu hide the item.
+	 */
+	public function test_reports_menu_registers_admin_menu_handler(): void {
+		$this->reset_container_resolutions();
+		remove_all_actions( 'admin_menu' );
+		new WC_Admin_Menus();

-		call_user_func( $handler );
+		do_action( 'admin_menu', '' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment

-		$this->assertStringContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
+		$handler = array( wc_get_container()->get( LegacyReportsMenu::class ), 'handle_admin_menu' );
+		$this->assertSame( PHP_INT_MAX, has_action( 'admin_menu', $handler ), 'reports_menu() must hook the handler late on admin_menu' );
+		$this->assertTrue( $this->has_hide_class( 'submenu', 'wc-reports' ), 'The handler added during admin_menu must run in the same admin_menu' );
 	}

 	/**
 	 * @testdox Should hide the WooCommerce > Reports item on a new store but keep the page accessible.
 	 */
-	public function test_admin_head_hides_submenu_item_and_keeps_page_accessible(): void {
+	public function test_admin_menu_hides_submenu_item_and_keeps_page_accessible(): void {
 		$this->register_woocommerce_menu();

-		$this->sut->handle_admin_head();
+		$this->sut->handle_admin_menu();

 		$this->assertStringContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
 		$this->assertStringNotContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-settings' ), 'Other items must stay visible' );
@@ -383,11 +408,11 @@ class LegacyReportsMenuTest extends WC_Unit_Test_Case {
 	/**
 	 * @testdox Should not hide the WooCommerce > Reports item on an existing store.
 	 */
-	public function test_admin_head_keeps_submenu_item_on_existing_store(): void {
+	public function test_admin_menu_keeps_submenu_item_on_existing_store(): void {
 		update_option( WC_Install::INITIAL_INSTALLED_VERSION, '11.2.0' );
 		$this->register_woocommerce_menu();

-		$this->sut->handle_admin_head();
+		$this->sut->handle_admin_menu();

 		$this->assertStringNotContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
 	}
@@ -395,10 +420,10 @@ class LegacyReportsMenuTest extends WC_Unit_Test_Case {
 	/**
 	 * @testdox Should not hide Reports when it is the first WooCommerce submenu item, since WordPress links the parent to it.
 	 */
-	public function test_admin_head_keeps_reports_when_first_submenu_item(): void {
+	public function test_admin_menu_keeps_reports_when_first_submenu_item(): void {
 		$GLOBALS['submenu']['woocommerce'] = array( array( 'Reports', 'view_woocommerce_reports', 'wc-reports', 'Reports' ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited

-		$this->sut->handle_admin_head();
+		$this->sut->handle_admin_menu();

 		$this->assertStringNotContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'submenu', 'wc-reports' ) );
 	}
@@ -406,12 +431,12 @@ class LegacyReportsMenuTest extends WC_Unit_Test_Case {
 	/**
 	 * @testdox Should not recreate a Reports item that another plugin removed.
 	 */
-	public function test_admin_head_does_not_recreate_removed_item(): void {
+	public function test_admin_menu_does_not_recreate_removed_item(): void {
 		$this->register_woocommerce_menu();
 		remove_submenu_page( 'woocommerce', 'wc-reports' );
 		$submenu_before = $GLOBALS['submenu'];

-		$this->sut->handle_admin_head();
+		$this->sut->handle_admin_menu();

 		$this->assertSame( $submenu_before, $GLOBALS['submenu'] );
 	}
@@ -419,12 +444,12 @@ class LegacyReportsMenuTest extends WC_Unit_Test_Case {
 	/**
 	 * @testdox Should hide the top-level Sales reports item for users who can view reports but not the WooCommerce menu.
 	 */
-	public function test_admin_head_hides_top_level_item_for_reports_only_user(): void {
+	public function test_admin_menu_hides_top_level_item_for_reports_only_user(): void {
 		wp_set_current_user( $this->create_reports_only_user() );
 		$this->assertFalse( WC_Admin_Menus::can_view_woocommerce_menu_item(), 'Precondition: user cannot see the WooCommerce menu' );

 		( new WC_Admin_Menus() )->reports_menu();
-		$this->sut->handle_admin_head();
+		$this->sut->handle_admin_menu();

 		$this->assertStringContainsString( WC_Admin_Menus::HIDE_CSS_CLASS, $this->get_menu_item_classes( 'menu', 'wc-reports' ) );
 		$this->assertTrue( $this->can_access_reports_page(), 'The reports page must stay accessible by URL' );
@@ -437,13 +462,13 @@ class LegacyReportsMenuTest extends WC_Unit_Test_Case {
 		wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber' ) ) );

 		( new WC_Admin_Menus() )->reports_menu();
-		$this->sut->handle_admin_head();
+		$this->sut->handle_admin_menu();

 		$this->assertFalse( $this->can_access_reports_page() );
 	}

 	/**
-	 * Build the WooCommerce menu, run the admin_head handler on a fresh instance, and report whether Reports is visible.
+	 * Build the WooCommerce menu, run the admin_menu handler on a fresh instance, and report whether Reports is visible.
 	 *
 	 * @return bool
 	 */
@@ -452,7 +477,7 @@ class LegacyReportsMenuTest extends WC_Unit_Test_Case {
 		$GLOBALS['submenu'] = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
 		$this->register_woocommerce_menu();

-		( new LegacyReportsMenu() )->handle_admin_head();
+		( new LegacyReportsMenu() )->handle_admin_menu();

 		return false === strpos( $this->get_menu_item_classes( 'submenu', 'wc-reports' ), WC_Admin_Menus::HIDE_CSS_CLASS );
 	}
@@ -479,6 +504,17 @@ class LegacyReportsMenuTest extends WC_Unit_Test_Case {
 		return $user_id;
 	}

+	/**
+	 * Whether the wc-* item in $menu or $submenu['woocommerce'] has the hide class.
+	 *
+	 * @param string $menu_global The global to search: 'menu' or 'submenu'.
+	 * @param string $slug        The menu slug.
+	 * @return bool
+	 */
+	private function has_hide_class( string $menu_global, string $slug ): bool {
+		return false !== strpos( $this->get_menu_item_classes( $menu_global, $slug ), WC_Admin_Menus::HIDE_CSS_CLASS );
+	}
+
 	/**
 	 * Get the CSS classes of the wc-* item in $menu or $submenu['woocommerce'].
 	 *