Commit 835329e15bb for woocommerce

commit 835329e15bb556ff6eaf7e878069c9d55b213375
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Fri Jul 24 15:27:11 2026 +0300

    Fix: Persist nav-menu hidden snapshot so later plugin boxes stay visible (#66954)

    * fix: persist nav-menu hidden snapshot so late plugin boxes stay visible

    * test: scope nav-menu filter teardown to this test's callback

diff --git a/plugins/woocommerce/changelog/66952-fix-nav-menu-hidden-snapshot b/plugins/woocommerce/changelog/66952-fix-nav-menu-hidden-snapshot
new file mode 100644
index 00000000000..92406ee1ef5
--- /dev/null
+++ b/plugins/woocommerce/changelog/66952-fix-nav-menu-hidden-snapshot
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Persist the default nav-menu hidden meta box snapshot on a user's first visit to Appearance > Menus, so meta boxes registered by plugins installed after that visit stay visible by default instead of being hidden on every recompute.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
index c910afa8527..8bcef39f77b 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-menus.php
@@ -31,22 +31,6 @@ class WC_Admin_Menus {
 	 */
 	const HIDE_CSS_CLASS = 'hide-if-js';

-	/**
-	 * Cached hidden meta box IDs from the first read of this request.
-	 *
-	 * The metaboxhidden_nav-menus option is read more than once per page load:
-	 * once before admin_head fires and again inside do_accordion_sections()
-	 * after admin_head-nav-menus.php has fired. Memoizing the computed list
-	 * keeps the value stable across those reads, so a box registered late
-	 * (e.g. the "WooCommerce endpoints" box) is not swept into the hidden list
-	 * on the second read. This mirrors how core persists the snapshot before
-	 * admin_head, and fixes the general case for third-party boxes too.
-	 *
-	 * @since 11.1.0
-	 * @var string[]|null
-	 */
-	private $last_computed_hidden_boxes = null;
-
 	/**
 	 * Hook in tabs.
 	 */
@@ -455,10 +439,12 @@ class WC_Admin_Menus {
 	 * taxonomy boxes, so WP's existing-user guard short-circuits and leaves
 	 * them visible.
 	 *
-	 * The hidden list is computed once per request and cached on the instance,
-	 * so every read of the option within the same page load returns the same
-	 * value. This keeps late-registered boxes out of the hidden list and restores
-	 * the snapshot timing core relied on before admin_head.
+	 * The computed list is persisted to user meta on that first read, mirroring
+	 * core's wp_initial_nav_menu_meta_boxes(). This keeps the snapshot stable
+	 * across the multiple reads in a single page load and, crucially, across
+	 * later requests: boxes registered by plugins installed after the first
+	 * visit stay visible, matching vanilla WordPress instead of defaulting to
+	 * hidden on every recompute.
 	 *
 	 * @since 11.1.0
 	 *
@@ -486,6 +472,9 @@ class WC_Admin_Menus {
 	public function filter_default_nav_menu_hidden_meta_boxes( $result, $option, $user ) {
 		global $wp_meta_boxes;

+		// Once a snapshot exists the option reads non-false, so this early return
+		// also makes the update_user_meta() call below idempotent: a re-entrant read
+		// bails here instead of persisting again.
 		if ( false !== $result ) {
 			return $result;
 		}
@@ -494,10 +483,6 @@ class WC_Admin_Menus {
 			return $result;
 		}

-		if ( null !== $this->last_computed_hidden_boxes ) {
-			return $this->last_computed_hidden_boxes;
-		}
-
 		$visible = array(
 			'add-post-type-page',
 			'add-post-type-post',
@@ -523,7 +508,10 @@ class WC_Admin_Menus {
 			}
 		}

-		$this->last_computed_hidden_boxes = $hidden;
+		// Persist the snapshot, mirroring core's wp_initial_nav_menu_meta_boxes(),
+		// so later reads in this request and in future requests return this same
+		// list instead of recomputing it against a changed meta box registry.
+		update_user_meta( $user->ID, 'metaboxhidden_nav-menus', $hidden );

 		return $hidden;
 	}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-admin-menus-test.php b/plugins/woocommerce/tests/php/includes/class-wc-admin-menus-test.php
index 8e1554d7538..85e3f284e2f 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-admin-menus-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-admin-menus-test.php
@@ -202,8 +202,8 @@ class WC_Admin_Menus_Test extends WC_Unit_Test_Case {
 	 * A box registered after admin_head-nav-menus.php fires (the "WooCommerce
 	 * endpoints" box) must stay visible. The option is read twice on the same
 	 * page load: once before admin_head and again inside do_accordion_sections()
-	 * after admin_head-nav-menus.php. Memoization keeps the hidden list stable
-	 * across both reads so the late-registered box is not swept into it.
+	 * after admin_head-nav-menus.php. The persisted snapshot keeps the hidden
+	 * list stable across both reads so the late-registered box is not swept into it.
 	 */
 	public function test_filter_keeps_endpoints_box_visible_after_late_registration() {
 		$GLOBALS['wp_meta_boxes'] = $this->get_nav_meta_boxes();  // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
@@ -229,4 +229,88 @@ class WC_Admin_Menus_Test extends WC_Unit_Test_Case {
 		$this->assertSame( $hidden_before, $hidden_after );
 		$this->assertNotContains( 'woocommerce_endpoints_nav_link', $hidden_after );
 	}
+
+	/**
+	 * A non-whitelisted box registered late in the same request must not be
+	 * swept into the hidden list on the second read. The snapshot is captured
+	 * on the first read; a box that did not exist then stays out of it, matching
+	 * WordPress core, which snapshots once before admin_head fires. This guards
+	 * the stability mechanism with a box the visible allowlist does not exclude.
+	 */
+	public function test_filter_keeps_late_registered_third_party_box_out_of_snapshot() {
+		$GLOBALS['wp_meta_boxes'] = $this->get_nav_meta_boxes();  // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$user_id                  = $this->factory->user->create();
+
+		$menus = new WC_Admin_Menus();
+		$menus->register_default_nav_menu_meta_boxes_filter();
+
+		$hidden_before = get_user_option( 'metaboxhidden_nav-menus', $user_id );
+
+		// Simulate a third-party box registered late (e.g. on admin_head-nav-menus.php).
+		$GLOBALS['wp_meta_boxes']['nav-menus']['side']['low']['add-late-third-party'] = array( // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			'id'    => 'add-late-third-party',
+			'title' => 'Late third party',
+		);
+
+		$hidden_after = get_user_option( 'metaboxhidden_nav-menus', $user_id );
+
+		$this->assertSame( $hidden_before, $hidden_after );
+		$this->assertNotContains( 'add-late-third-party', $hidden_after );
+	}
+
+	/**
+	 * On a first-time user's visit the filter must persist the computed hidden
+	 * snapshot to user meta, mirroring WordPress core's wp_initial_nav_menu_meta_boxes().
+	 * Without persistence, the option reads false on every later request and the
+	 * hidden list is recomputed from the live meta box registry each time.
+	 */
+	public function test_filter_persists_hidden_snapshot_on_first_visit() {
+		$GLOBALS['wp_meta_boxes'] = $this->get_nav_meta_boxes();  // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$user_id                  = $this->factory->user->create();
+
+		$menus = new WC_Admin_Menus();
+		$menus->register_default_nav_menu_meta_boxes_filter();
+
+		get_user_option( 'metaboxhidden_nav-menus', $user_id );
+
+		$persisted = get_user_meta( $user_id, 'metaboxhidden_nav-menus', true );
+
+		$this->assertIsArray( $persisted );
+		$this->assertContains( 'add-post-type-news', $persisted );
+		$this->assertNotContains( 'add-product_cat', $persisted );
+	}
+
+	/**
+	 * Once a snapshot is persisted, a third-party box registered on a later
+	 * request must stay visible, matching vanilla WordPress. The persisted
+	 * snapshot short-circuits the recompute, so the newly registered box is
+	 * not added to the hidden list.
+	 */
+	public function test_snapshot_persists_so_later_registered_third_party_box_stays_visible() {
+		$GLOBALS['wp_meta_boxes'] = $this->get_nav_meta_boxes();  // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$user_id                  = $this->factory->user->create();
+
+		// First request: user visits the nav-menus screen for the first time.
+		$first_visit = new WC_Admin_Menus();
+		$first_visit->register_default_nav_menu_meta_boxes_filter();
+		get_user_option( 'metaboxhidden_nav-menus', $user_id );
+
+		// End of the first request: its hook and instance no longer exist. Remove
+		// only this instance's callback so the test stays isolated from other hooks.
+		remove_filter( 'get_user_option_metaboxhidden_nav-menus', array( $first_visit, 'filter_default_nav_menu_hidden_meta_boxes' ), 10 );
+
+		// A plugin registers a nav-menus meta box after that first visit.
+		$GLOBALS['wp_meta_boxes']['nav-menus']['side']['default']['add-later-plugin-box'] = array( // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			'id'    => 'add-later-plugin-box',
+			'title' => 'Later plugin box',
+		);
+
+		// Second request: a fresh instance handles the later page load.
+		$second_visit = new WC_Admin_Menus();
+		$second_visit->register_default_nav_menu_meta_boxes_filter();
+		$hidden = get_user_option( 'metaboxhidden_nav-menus', $user_id );
+
+		$this->assertIsArray( $hidden );
+		$this->assertNotContains( 'add-later-plugin-box', $hidden );
+	}
 }