diff --git a/src/js/_enqueues/lib/admin-bar.js b/src/js/_enqueues/lib/admin-bar.js
index eb18c61f8b5b8..ea3c995c85360 100644
--- a/src/js/_enqueues/lib/admin-bar.js
+++ b/src/js/_enqueues/lib/admin-bar.js
@@ -108,6 +108,61 @@
if ( adminBarLogout ) {
adminBarLogout.addEventListener( 'click', emptySessionStorage );
}
+
+ /*
+ * My Sites fly-out positioning.
+ *
+ * When the My Sites dropdown is scrollable (overflow-y: auto), the nested
+ * fly-out submenus (Dashboard, New Post, etc.) are clipped by the scroll
+ * container. The CSS switches them to position: fixed, and this JS computes
+ * their coordinates from getBoundingClientRect() so they appear next to
+ * the hovered site. The fly-out is clamped so it never extends below the
+ * viewport.
+ *
+ * @since 7.0.0
+ * @see https://core.trac.wordpress.org/ticket/15317
+ */
+ var mySitesWrapper = document.querySelector( '#wp-admin-bar-my-sites > .ab-sub-wrapper' );
+
+ if ( mySitesWrapper ) {
+ mySitesWrapper.addEventListener( 'mouseover', function( e ) {
+ var li = getClosest( e.target, '.menupop' );
+
+ if ( ! li || li.id === 'wp-admin-bar-my-sites' ) {
+ return;
+ }
+
+ var sub = li.querySelector( ':scope > .ab-sub-wrapper' );
+
+ if ( ! sub ) {
+ return;
+ }
+
+ var rect = li.getBoundingClientRect();
+ var top = rect.top;
+ var isRTL = ( document.documentElement.dir === 'rtl' );
+
+ // Measure the fly-out to keep it inside the viewport.
+ sub.style.visibility = 'hidden';
+ sub.style.display = 'block';
+ var subHeight = sub.offsetHeight;
+ var subWidth = sub.offsetWidth;
+ 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' );
+
+ if ( isRTL ) {
+ li.style.setProperty( '--msf-left', Math.max( 0, rect.left - subWidth ) + 'px' );
+ } else {
+ li.style.setProperty( '--msf-left', rect.right + 'px' );
+ }
+ } );
+ }
} );
/**
diff --git a/src/wp-includes/admin-bar.php b/src/wp-includes/admin-bar.php
index b9c7872d0cc07..5ba005c0f121f 100644
--- a/src/wp-includes/admin-bar.php
+++ b/src/wp-includes/admin-bar.php
@@ -675,18 +675,30 @@ function wp_admin_bar_my_sites_menu( $wp_admin_bar ) {
*
* @param bool $show_site_icons Whether site icons should be shown in the toolbar. Default true.
*/
- $show_site_icons = apply_filters( 'wp_admin_bar_show_site_icons', true );
-
- foreach ( (array) $wp_admin_bar->user->blogs as $blog ) {
- switch_to_blog( $blog->userblog_id );
+ $blogs = (array) $wp_admin_bar->user->blogs;
+ $show_site_icons = apply_filters( 'wp_admin_bar_show_site_icons', count( $blogs ) <= 20 );
+
+ foreach ( $blogs as $blog ) {
+ $blog_id = (int) $blog->userblog_id;
+ $siteurl = untrailingslashit( $blog->siteurl );
+ $adminurl = $siteurl . '/wp-admin';
+ $homeurl = esc_url( set_url_scheme( 'http://' . $blog->domain . $blog->path ) );
+
+ if ( $show_site_icons ) {
+ switch_to_blog( $blog_id );
+
+ if ( has_site_icon() ) {
+ $blavatar = sprintf(
+ '
',
+ esc_url( get_site_icon_url( 16 ) ),
+ esc_url( get_site_icon_url( 32 ) ),
+ ( wp_lazy_loading_enabled( 'img', 'site_icon_in_toolbar' ) ? ' loading="lazy"' : '' )
+ );
+ } else {
+ $blavatar = '
';
+ }
- if ( true === $show_site_icons && has_site_icon() ) {
- $blavatar = sprintf(
- '
',
- esc_url( get_site_icon_url( 16 ) ),
- esc_url( get_site_icon_url( 32 ) ),
- ( wp_lazy_loading_enabled( 'img', 'site_icon_in_toolbar' ) ? ' loading="lazy"' : '' )
- );
+ restore_current_blog();
} else {
$blavatar = '';
}
@@ -694,72 +706,55 @@ function wp_admin_bar_my_sites_menu( $wp_admin_bar ) {
$blogname = $blog->blogname;
if ( ! $blogname ) {
- $blogname = preg_replace( '#^(https?://)?(www\.)?#', '', get_home_url() );
+ $blogname = preg_replace( '#^(https?://)?(www\.)?#', '', $siteurl );
}
- $menu_id = 'blog-' . $blog->userblog_id;
+ $menu_id = 'blog-' . $blog_id;
- if ( current_user_can( 'read' ) ) {
- $wp_admin_bar->add_node(
- array(
- 'parent' => 'my-sites-list',
- 'id' => $menu_id,
- 'title' => $blavatar . $blogname,
- 'href' => admin_url(),
- )
- );
+ $wp_admin_bar->add_node(
+ array(
+ 'parent' => 'my-sites-list',
+ 'id' => $menu_id,
+ 'title' => $blavatar . esc_html( $blogname ),
+ 'href' => $adminurl,
+ )
+ );
- $wp_admin_bar->add_node(
- array(
- 'parent' => $menu_id,
- 'id' => $menu_id . '-d',
- 'title' => __( 'Dashboard' ),
- 'href' => admin_url(),
- )
- );
- } else {
- $wp_admin_bar->add_node(
- array(
- 'parent' => 'my-sites-list',
- 'id' => $menu_id,
- 'title' => $blavatar . $blogname,
- 'href' => home_url(),
- )
- );
- }
+ $wp_admin_bar->add_node(
+ array(
+ 'parent' => $menu_id,
+ 'id' => $menu_id . '-d',
+ 'title' => __( 'Dashboard' ),
+ 'href' => $adminurl,
+ )
+ );
- if ( current_user_can( get_post_type_object( 'post' )->cap->create_posts ) ) {
- $wp_admin_bar->add_node(
- array(
- 'parent' => $menu_id,
- 'id' => $menu_id . '-n',
- 'title' => get_post_type_object( 'post' )->labels->new_item,
- 'href' => admin_url( 'post-new.php' ),
- )
- );
- }
+ $wp_admin_bar->add_node(
+ array(
+ 'parent' => $menu_id,
+ 'id' => $menu_id . '-n',
+ 'title' => __( 'New Post' ),
+ 'href' => $adminurl . '/post-new.php',
+ )
+ );
- if ( current_user_can( 'edit_posts' ) ) {
- $wp_admin_bar->add_node(
- array(
- 'parent' => $menu_id,
- 'id' => $menu_id . '-c',
- 'title' => __( 'Manage Comments' ),
- 'href' => admin_url( 'edit-comments.php' ),
- )
- );
- }
+ $wp_admin_bar->add_node(
+ array(
+ 'parent' => $menu_id,
+ 'id' => $menu_id . '-c',
+ 'title' => __( 'Manage Comments' ),
+ 'href' => $adminurl . '/edit-comments.php',
+ )
+ );
$wp_admin_bar->add_node(
array(
'parent' => $menu_id,
'id' => $menu_id . '-v',
'title' => __( 'Visit Site' ),
- 'href' => home_url( '/' ),
+ 'href' => $homeurl,
)
);
-
- restore_current_blog();
}
}
diff --git a/src/wp-includes/css/admin-bar.css b/src/wp-includes/css/admin-bar.css
index 0b8d4ab41a809..d4a20dd38fc9e 100644
--- a/src/wp-includes/css/admin-bar.css
+++ b/src/wp-includes/css/admin-bar.css
@@ -763,6 +763,32 @@ html:lang(he-il) .rtl #wpadminbar * {
box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.6);
}
+/*
+ * My Sites scrollable dropdown.
+ *
+ * When the site list exceeds the viewport height, allow the menu to scroll.
+ * Fly-out submenus use position: fixed to escape the scroll container's
+ * overflow clipping. Coordinates are set via CSS custom properties from JS.
+ *
+ * @since 7.0.0
+ * @see https://core.trac.wordpress.org/ticket/15317
+ */
+@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;
+ }
+
+ /* 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);
+ }
+}
+
@media screen and (max-width: 782px) {
html {
--wp-admin--admin-bar--height: 46px;
diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php
index 9c635f63d288a..031cb7340d0a3 100644
--- a/src/wp-includes/user.php
+++ b/src/wp-includes/user.php
@@ -1080,6 +1080,57 @@ function get_blogs_of_user( $user_id, $all = false ) {
return $sites;
}
+ /*
+ * Super admins have implicit access to all sites on the network,
+ * but only have explicit `_capabilities` meta rows for sites they
+ * were individually added to. Use get_sites() so they see every site.
+ *
+ * @since 7.0.0
+ */
+ if ( is_super_admin( $user_id ) ) {
+ $args = array(
+ 'orderby' => 'path',
+ 'number' => 0, // All sites.
+ );
+ if ( ! $all ) {
+ $args['archived'] = 0;
+ $args['spam'] = 0;
+ $args['deleted'] = 0;
+ $args['mature'] = 0;
+ }
+
+ $_sites = get_sites( $args );
+
+ $_site_ids = array_map(
+ function ( $s ) {
+ return (int) $s->id;
+ },
+ $_sites
+ );
+
+ $_site_options = _batch_get_site_options( $_site_ids, array( 'blogname', 'siteurl' ) );
+
+ $sites = array();
+ foreach ( $_sites as $site ) {
+ $id = (int) $site->id;
+ $sites[ $id ] = (object) array(
+ 'userblog_id' => $id,
+ 'blogname' => isset( $_site_options[ $id ]['blogname'] ) ? $_site_options[ $id ]['blogname'] : '',
+ 'domain' => $site->domain,
+ 'path' => $site->path,
+ 'site_id' => (int) $site->network_id,
+ 'siteurl' => isset( $_site_options[ $id ]['siteurl'] ) ? $_site_options[ $id ]['siteurl'] : '',
+ 'archived' => $site->archived,
+ 'mature' => $site->mature,
+ 'spam' => $site->spam,
+ 'deleted' => $site->deleted,
+ );
+ }
+
+ /** This filter is documented in wp-includes/user.php */
+ return apply_filters( 'get_blogs_of_user', $sites, $user_id, $all );
+ }
+
$site_ids = array();
if ( isset( $keys[ $wpdb->base_prefix . 'capabilities' ] ) && defined( 'MULTISITE' ) ) {
@@ -1119,14 +1170,36 @@ function get_blogs_of_user( $user_id, $all = false ) {
$_sites = get_sites( $args );
+ /*
+ * Batch-fetch blogname and siteurl for all sites in a single query.
+ *
+ * Accessing $site->blogname or $site->siteurl on a WP_Site object
+ * triggers WP_Site::get_details(), which calls switch_to_blog()
+ * internally for each site. On large networks this is expensive.
+ *
+ * Instead, build a UNION ALL query across per-site options tables
+ * to fetch both values for all sites at once.
+ *
+ * @since 7.0.0
+ */
+ $_site_ids = array_map(
+ function ( $s ) {
+ return (int) $s->id;
+ },
+ $_sites
+ );
+
+ $_site_options = _batch_get_site_options( $_site_ids, array( 'blogname', 'siteurl' ) );
+
foreach ( $_sites as $site ) {
- $sites[ $site->id ] = (object) array(
- 'userblog_id' => $site->id,
- 'blogname' => $site->blogname,
+ $id = (int) $site->id;
+ $sites[ $id ] = (object) array(
+ 'userblog_id' => $id,
+ 'blogname' => isset( $_site_options[ $id ]['blogname'] ) ? $_site_options[ $id ]['blogname'] : '',
'domain' => $site->domain,
'path' => $site->path,
'site_id' => $site->network_id,
- 'siteurl' => $site->siteurl,
+ 'siteurl' => isset( $_site_options[ $id ]['siteurl'] ) ? $_site_options[ $id ]['siteurl'] : '',
'archived' => $site->archived,
'mature' => $site->mature,
'spam' => $site->spam,
@@ -1148,6 +1221,57 @@ function get_blogs_of_user( $user_id, $all = false ) {
return apply_filters( 'get_blogs_of_user', $sites, $user_id, $all );
}
+/**
+ * Batch-fetch option values for multiple sites in a single SQL query.
+ *
+ * Uses $wpdb->get_blog_prefix() (a pure function with no side effects) to
+ * build a UNION ALL query across per-site options tables. This avoids the
+ * hidden switch_to_blog() calls triggered by WP_Site::get_details() when
+ * accessing magic properties like blogname or siteurl.
+ *
+ * @since 7.0.0
+ * @access private
+ *
+ * @global wpdb $wpdb WordPress database abstraction object.
+ *
+ * @param int[] $site_ids Array of site IDs.
+ * @param string[] $option_names Array of option names to fetch.
+ * @return array> Keyed by site ID, then option name.
+ */
+function _batch_get_site_options( array $site_ids, array $option_names ) {
+ if ( empty( $site_ids ) || empty( $option_names ) ) {
+ return array();
+ }
+
+ global $wpdb;
+
+ $name_placeholders = implode( ',', array_fill( 0, count( $option_names ), '%s' ) );
+
+ $union_parts = array();
+ foreach ( $site_ids as $site_id ) {
+ $site_id = (int) $site_id;
+ $prefix = $wpdb->get_blog_prefix( $site_id );
+ $table = $prefix . 'options';
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->get_blog_prefix().
+ $union_parts[] = $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table and $name_placeholders are safe.
+ "SELECT %d AS blog_id, option_name, option_value FROM {$table} WHERE option_name IN ({$name_placeholders})",
+ array_merge( array( $site_id ), $option_names )
+ );
+ }
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- each part is prepared above.
+ $rows = $wpdb->get_results( implode( ' UNION ALL ', $union_parts ) );
+
+ $options = array();
+ foreach ( $rows as $row ) {
+ $options[ (int) $row->blog_id ][ $row->option_name ] = $row->option_value;
+ }
+
+ return $options;
+}
+
/**
* Finds out whether a user is a member of a given blog.
*