Skip to content

soderlind/my-sites-fix

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Minimal Fix for WordPress Core "My Sites" Menu

Based on analysis of WordPress 7 Beta 5 source code and the Super Admin All Sites Menu plugin.


The Three Bugs

Bug 1 — Super Admins only see sites where they are a local member

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

Bug 2 — Dropdown can't scroll past the viewport (Trac #15317, open since 2010)

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.

Bug 3 — switch_to_blog() called for every site

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.


Minimal Fixes

Fix 1 — Show all network sites for super admins

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.

Fix 2 — Make the dropdown scrollable

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.

Caveat: overflow-y: auto breaks nested fly-out submenus

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

Fix 3 — Eliminate switch_to_blog() from the menu loop

3a. Admin bar: wp_admin_bar_my_sites_menu() in admin-bar.php

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:

  1. 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 );
  2. Capability checks — for super admins, current_user_can() always returns true regardless of switched context. For regular users, they were found via _capabilities meta, so read is guaranteed. Skip the create_posts/edit_posts checks or assume they can (the entries are just links — the target page enforces permissions anyway).

  3. Site icons — batch-query the site_icon option for all sites using $wpdb->get_blog_prefix(), same technique as the plugin's batch_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 the wp_admin_bar_show_site_icons filter to false for networks with > N sites.

  4. blogname / home fallback — already available from the blog object; no switch needed.

3b. My Sites page: my-sites.php

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,   // same

Fix: 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.


Summary: Files Changed

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

What the Plugin Does Differently (reference)

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors