Based on analysis of WordPress 7 Beta 5 source code and the Super Admin All Sites Menu plugin.
WP_Admin_Bar::initialize() (class-wp-admin-bar.php:41) populates the site list via:
$this->user->blogs = get_blogs_of_user( get_current_user_id() );get_blogs_of_user() (user.php:1034) discovers sites by scanning user meta keys ending
in _capabilities. A super admin has implicit access to all sites, but only has explicit
capabilities rows for sites they were individually added to. Result: the menu is missing
most sites on a large network.
The admin page (my-sites.php:22) uses the same function:
$blogs = get_blogs_of_user( $current_user->ID );admin-bar.css applies no max-height or overflow-y to
#wp-admin-bar-my-sites > .ab-sub-wrapper.
When the site list exceeds the viewport height, items below the fold are unreachable.
Both wp_admin_bar_my_sites_menu() (admin-bar.php:681) and the My Sites admin page
(my-sites.php:134) loop over every site calling switch_to_blog() /
restore_current_blog().
Additionally, get_blogs_of_user() itself triggers hidden switches: it calls
get_sites() and then reads $site->blogname and $site->siteurl on each WP_Site object
(user.php:1139-1140), which triggers WP_Site::get_details() → switch_to_blog()
internally.
File: wp-includes/class-wp-admin-bar.php (line 41)
Replace the get_blogs_of_user() call with a conditional:
// BEFORE:
$this->user->blogs = get_blogs_of_user( get_current_user_id() );
// AFTER:
if ( is_multisite() && current_user_can( 'manage_network' ) ) {
$sites = get_sites( [
'orderby' => 'path',
'number' => 0, // all sites
'deleted' => '0',
'mature' => '0',
'archived' => '0',
'spam' => '0',
] );
$this->user->blogs = [];
foreach ( $sites as $site ) {
$this->user->blogs[ $site->id ] = (object) [
'userblog_id' => $site->id,
'blogname' => $site->blogname,
'domain' => $site->domain,
'path' => $site->path,
'site_id' => $site->network_id,
'siteurl' => $site->siteurl,
'archived' => $site->archived,
'mature' => $site->mature,
'spam' => $site->spam,
'deleted' => $site->deleted,
];
}
} else {
$this->user->blogs = get_blogs_of_user( get_current_user_id() );
}File: wp-admin/my-sites.php (line 22) — same change: use get_sites() when
current_user_can( 'manage_network' ).
Note: $site->blogname and $site->siteurl above still trigger
WP_Site::get_details() → switch_to_blog(). Combine with Fix 3 to eliminate that cost.
File: wp-includes/css/admin-bar.css
Add one rule (inside a desktop media query to avoid touching the mobile menu):
@media screen and (min-width: 783px) {
#wpadminbar .ab-top-menu > li#wp-admin-bar-my-sites > .ab-sub-wrapper {
max-height: calc(100vh - var(--wp-admin--admin-bar--height, 32px));
overflow-y: auto;
}
}This is the same approach used by the plugin's css/all-sites-menu.css.
Closes Trac #15317.
Adding overflow-y: auto to the scroll wrapper implicitly sets overflow-x to auto as
well (per the CSS overflow spec). This clips the per-site fly-out submenus (Dashboard,
New Post, etc.) that position themselves with margin-left: 100% to appear to the right of
the wrapper.
Fix: Switch the nested fly-outs to position: fixed and compute their coordinates in
JavaScript from getBoundingClientRect(). The JS also clamps the fly-out's top position so
it never extends below the viewport.
CSS (css/my-sites-fix.css):
/* Fly-out submenus: fixed position to escape the scroll container */
#wpadminbar #wp-admin-bar-my-sites .ab-sub-wrapper .menupop > .ab-sub-wrapper {
position: fixed !important;
margin-left: 0 !important;
margin-top: 0 !important;
top: var(--msf-top, 0);
left: var(--msf-left, 0);
}JS (js/my-sites-fix.js):
wrapper.addEventListener( 'mouseover', function ( e ) {
var li = e.target.closest( '.menupop' );
var sub = li.querySelector( ':scope > .ab-sub-wrapper' );
var rect = li.getBoundingClientRect();
var top = rect.top;
// Measure the fly-out to keep it inside the viewport.
sub.style.visibility = 'hidden';
sub.style.display = 'block';
var subHeight = sub.offsetHeight;
sub.style.removeProperty( 'visibility' );
sub.style.removeProperty( 'display' );
if ( top + subHeight > window.innerHeight ) {
top = Math.max( 0, window.innerHeight - subHeight );
}
li.style.setProperty( '--msf-top', top + 'px' );
li.style.setProperty( '--msf-left', rect.right + 'px' );
} );Current code (lines 681–762) calls switch_to_blog() per site to:
| Call | Why it needs the switch |
|---|---|
has_site_icon() / get_site_icon_url() |
Reads site_icon option from per-site table |
current_user_can( 'read' ) |
Checks site-local capabilities |
current_user_can( ...->cap->create_posts ) |
Checks site-local capabilities |
admin_url() |
Returns URL based on siteurl option |
home_url() |
Returns URL based on home option |
Minimal replacement:
-
URLs — compute directly from the blog object (already populated by
get_blogs_of_user()):$siteurl = $blog->siteurl; $adminurl = $siteurl . '/wp-admin'; $homeurl = untrailingslashit( $blog->domain . $blog->path );
-
Capability checks — for super admins,
current_user_can()always returnstrueregardless of switched context. For regular users, they were found via_capabilitiesmeta, soreadis guaranteed. Skip thecreate_posts/edit_postschecks or assume they can (the entries are just links — the target page enforces permissions anyway). -
Site icons — batch-query the
site_iconoption for all sites using$wpdb->get_blog_prefix(), same technique as the plugin'sbatch_get_site_options(). However, resolving the icon attachment URL still requires a switch (or another batch query against{prefix}posts+{prefix}postmeta). Simplest pragmatic fix: default thewp_admin_bar_show_site_iconsfilter tofalsefor networks with > N sites. -
blogname / home fallback — already available from the blog object; no switch needed.
Same fix: compute URLs from the blog object, skip capability check, fire filters with pre-computed context.
3c. get_blogs_of_user() hidden switch
File: wp-includes/user.php (lines 1139–1140)
'blogname' => $site->blogname, // triggers WP_Site::get_details() → switch_to_blog()
'siteurl' => $site->siteurl, // sameFix: use a batch UNION ALL query to pre-fetch blogname and siteurl for all $site_ids
in one query before the loop, then assign from the batch result instead of via magic getters.
| File | Change | Fixes |
|---|---|---|
wp-includes/class-wp-admin-bar.php |
Use get_sites() for super admins |
Bug 1 |
wp-admin/my-sites.php |
Use get_sites() for super admins |
Bug 1 |
wp-includes/css/admin-bar.css |
Add max-height + overflow-y |
Bug 2 (#15317) |
wp-includes/css/admin-bar.css |
Fixed-position fly-outs to escape scroll clip | Bug 2 side-effect |
wp-includes/js/admin-bar.js |
Viewport-clamped fly-out positioning | Bug 2 side-effect |
wp-includes/admin-bar.php |
Remove switch_to_blog() from menu loop |
Bug 3 |
wp-admin/my-sites.php |
Remove switch_to_blog() from render loop |
Bug 3 |
wp-includes/user.php |
Batch-fetch blogname/siteurl in get_blogs_of_user() |
Bug 3 |
| Concern | Core My Sites | This Plugin |
|---|---|---|
| Site source | get_blogs_of_user() — user-member sites only |
get_sites() — all network sites |
| Scroll | No scroll on dropdown | overflow-y: auto; max-height: calc(100vh - 32px) |
| Fly-out submenus | Core uses margin-left: 100% (clipped by scroll overflow) |
position: fixed + JS viewport clamping |
| switch_to_blog | Per-site in PHP loop (blocking) | Zero — batch SQL via $wpdb->get_blog_prefix() |
| Rendering | Synchronous server-side | Async REST + IndexedDB + IntersectionObserver |
| Search | None | Client-side search by name or URL |
The plugin's architecture (async REST + client-side storage) is a much larger departure from core's server-side rendering. The fixes above stay within core's existing architecture while eliminating the three concrete bugs.