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 );
+ }
}