An open API service indexing awesome lists of open source software.

https://github.com/soderlind/my-sites-fix


https://github.com/soderlind/my-sites-fix

Last synced: about 1 month ago
JSON representation

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.

[![Launch in WordPress Playground](https://img.shields.io/badge/Launch%20in-WordPress%20Playground-3858e9?style=for-the-badge&logo=wordpress)](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.