https://github.com/soderlind/my-sites-fix
https://github.com/soderlind/my-sites-fix
Last synced: about 1 month ago
JSON representation
- Host: GitHub
- URL: https://github.com/soderlind/my-sites-fix
- Owner: soderlind
- Created: 2026-03-15T09:28:02.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-04-12T11:43:06.000Z (about 2 months ago)
- Last Synced: 2026-04-12T13:13:03.908Z (about 2 months ago)
- Language: PHP
- Size: 19.5 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# 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](https://github.com/soderlind/super-admin-all-sites-menu) plugin.
[](https://playground.wordpress.net/?networking=yes&a=c&blueprint-url=https://raw.githubusercontent.com/soderlind/my-sites-fix/main/blueprint.json)
---
## 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:
```php
$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:
```php
$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:
```php
// 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):
```css
@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](https://core.trac.wordpress.org/ticket/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`):
```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`):
```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()`):
```php
$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)
```php
'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.