diff --git a/src/wp-admin/admin-header.php b/src/wp-admin/admin-header.php
index 265c91923eea9..bfa84a11d8c8c 100644
--- a/src/wp-admin/admin-header.php
+++ b/src/wp-admin/admin-header.php
@@ -95,6 +95,10 @@
content_url && str_starts_with( $src, $this->content_url ) ) ) {
- $src = $this->base_url . $src;
- }
-
- $ver_to_add = '';
- if ( empty( $obj->ver ) && null !== $obj->ver && is_string( $this->default_version ) ) {
- $ver_to_add = $this->default_version;
- } elseif ( is_scalar( $obj->ver ) ) {
- $ver_to_add = (string) $obj->ver;
- }
-
- $added_args = (string) ( $this->args[ $handle ] ?? '' );
-
- if ( '' !== $ver_to_add || '' !== $added_args ) {
- $fragment = strstr( $src, '#' );
- if ( false !== $fragment ) {
- $src = substr( $src, 0, -strlen( $fragment ) );
- }
-
- if ( '' !== $ver_to_add ) {
- $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . 'ver=' . rawurlencode( $ver_to_add );
- }
- if ( '' !== $added_args ) {
- $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . $added_args;
- }
-
- if ( false !== $fragment ) {
- $src .= $fragment;
- }
- }
-
- /** This filter is documented in wp-includes/class-wp-scripts.php */
- $src = esc_url_raw( apply_filters( 'script_loader_src', $src, $handle ) );
+ $src = $this->get_src( $handle );
if ( ! $src ) {
return true;
@@ -506,6 +474,67 @@ public function do_item( $handle, $group = false ) {
return true;
}
+ /**
+ * Gets the URL a registered script is loaded from.
+ *
+ * This is the URL printed in the script's `src` attribute, including the version query
+ * argument and any arguments added to the handle, after the {@see 'script_loader_src'} filter.
+ *
+ * @since 7.2.0
+ *
+ * @param string $handle Script handle.
+ * @return string Script URL, or an empty string when the script is not registered, has no
+ * source of its own because it only aliases other scripts, or was filtered away.
+ */
+ public function get_src( string $handle ): string {
+ if ( ! isset( $this->registered[ $handle ] ) ) {
+ return '';
+ }
+
+ $obj = $this->registered[ $handle ];
+ $src = $obj->src;
+
+ if ( ! $src || ! is_string( $src ) ) {
+ return '';
+ }
+
+ if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $this->content_url && str_starts_with( $src, $this->content_url ) ) ) {
+ $src = $this->base_url . $src;
+ }
+
+ $ver_to_add = '';
+ if ( empty( $obj->ver ) && null !== $obj->ver && is_string( $this->default_version ) ) {
+ $ver_to_add = $this->default_version;
+ } elseif ( is_scalar( $obj->ver ) ) {
+ $ver_to_add = (string) $obj->ver;
+ }
+
+ $added_args = (string) ( $this->args[ $handle ] ?? '' );
+
+ if ( '' !== $ver_to_add || '' !== $added_args ) {
+ $fragment = strstr( $src, '#' );
+ if ( false !== $fragment ) {
+ $src = substr( $src, 0, -strlen( $fragment ) );
+ }
+
+ if ( '' !== $ver_to_add ) {
+ $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . 'ver=' . rawurlencode( $ver_to_add );
+ }
+ if ( '' !== $added_args ) {
+ $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . $added_args;
+ }
+
+ if ( false !== $fragment ) {
+ $src .= $fragment;
+ }
+ }
+
+ /** This filter is documented in wp-includes/class-wp-scripts.php */
+ $src = apply_filters( 'script_loader_src', $src, $handle );
+
+ return is_string( $src ) ? esc_url_raw( $src ) : '';
+ }
+
/**
* Adds extra code to a registered script.
*
diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php
index 531940951a5a1..643478ff4dd29 100644
--- a/src/wp-includes/class-wp-styles.php
+++ b/src/wp-includes/class-wp-styles.php
@@ -224,14 +224,9 @@ public function do_item( $handle, $group = false ) {
*/
$tag = apply_filters( 'style_loader_tag', $tag, $handle, $href, $media );
- if ( 'rtl' === $this->text_direction && isset( $obj->extra['rtl'] ) && $obj->extra['rtl'] ) {
- if ( is_bool( $obj->extra['rtl'] ) || 'replace' === $obj->extra['rtl'] ) {
- $suffix = $obj->extra['suffix'] ?? '';
- $rtl_href = str_replace( "{$suffix}.css", "-rtl{$suffix}.css", $this->_css_href( $src, $ver, "$handle-rtl" ) );
- } else {
- $rtl_href = $this->_css_href( $obj->extra['rtl'], $ver, "$handle-rtl" );
- }
+ $rtl_href = $this->get_rtl_href( $handle );
+ if ( null !== $rtl_href ) {
$rtl_tag = sprintf(
"\n",
$rel,
@@ -264,6 +259,54 @@ public function do_item( $handle, $group = false ) {
return true;
}
+ /**
+ * Gets the URL of the right-to-left stylesheet for a registered style.
+ *
+ * On a right-to-left locale, a style registered with `rtl` data is served by a separate
+ * stylesheet, which either replaces its left-to-right one (when the data is `'replace'`)
+ * or loads alongside it.
+ *
+ * @since 7.2.0
+ *
+ * @param string $handle The style's registered handle.
+ * @return string|null URL of the right-to-left stylesheet, after the {@see 'style_loader_src'}
+ * filter. Null when the text direction is not right-to-left, or the style is
+ * not registered, has no source of its own, or has no right-to-left variant.
+ */
+ public function get_rtl_href( string $handle ): ?string {
+ if ( 'rtl' !== $this->text_direction || ! isset( $this->registered[ $handle ] ) ) {
+ return null;
+ }
+
+ $obj = $this->registered[ $handle ];
+
+ if ( ! $obj->src || ! isset( $obj->extra['rtl'] ) || ! $obj->extra['rtl'] ) {
+ return null;
+ }
+
+ if ( null === $obj->ver ) {
+ $ver = '';
+ } else {
+ $ver = $obj->ver ? $obj->ver : $this->default_version;
+ }
+
+ if ( isset( $this->args[ $handle ] ) ) {
+ $ver = $ver ? $ver . '&' . $this->args[ $handle ] : $this->args[ $handle ];
+ }
+
+ if ( is_bool( $obj->extra['rtl'] ) || 'replace' === $obj->extra['rtl'] ) {
+ $suffix = isset( $obj->extra['suffix'] ) && is_string( $obj->extra['suffix'] ) ? $obj->extra['suffix'] : '';
+ return str_replace( "{$suffix}.css", "-rtl{$suffix}.css", $this->_css_href( $obj->src, $ver, "$handle-rtl" ) );
+ }
+
+ // Any other value is the URL of the right-to-left stylesheet itself.
+ if ( ! is_string( $obj->extra['rtl'] ) ) {
+ return null;
+ }
+
+ return $this->_css_href( $obj->extra['rtl'], $ver, "$handle-rtl" );
+ }
+
/**
* Adds extra CSS styles to a registered stylesheet.
*
diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index 025a371781200..f3c2fca81633b 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -396,6 +396,8 @@
add_action( 'login_head', 'wp_resource_hints', 8 );
add_action( 'login_head', 'wp_print_head_scripts', 9 );
add_action( 'login_head', 'print_admin_styles', 9 );
+add_action( 'login_head', 'wp_prefetch_admin_assets' );
+add_action( 'admin_head', 'wp_prefetch_admin_assets' );
add_action( 'login_head', 'wp_site_icon', 99 );
add_action( 'login_footer', 'wp_print_footer_scripts', 20 );
add_action( 'login_init', 'send_frame_options_header', 10, 0 );
diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php
index 393f50a3aad58..dd6113900f316 100644
--- a/src/wp-includes/functions.php
+++ b/src/wp-includes/functions.php
@@ -7664,6 +7664,7 @@ function wp_auth_check_load() {
* @param WP_Screen $screen The current screen object.
*/
if ( apply_filters( 'wp_auth_check_load', $show, $screen ) ) {
+ // Prefetched from the login screen by wp_prefetch_admin_assets(), which needs updating if this changes.
wp_enqueue_style( 'wp-auth-check' );
wp_enqueue_script( 'wp-auth-check' );
diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php
index 1e0b86655e82a..2ffa92f2edba0 100644
--- a/src/wp-includes/media.php
+++ b/src/wp-includes/media.php
@@ -5305,11 +5305,13 @@ function wp_enqueue_media( $args = array() ) {
wp_localize_script( 'media-views', '_wpMediaViewsL10n', $strings );
wp_enqueue_script( 'media-audiovideo' );
+ // Prefetched for the block editor by wp_prefetch_admin_assets(), which needs updating if this changes.
wp_enqueue_style( 'media-views' );
if ( is_admin() ) {
wp_enqueue_script( 'mce-view' );
wp_enqueue_script( 'image-edit' );
}
+ // Prefetched for the block editor by wp_prefetch_admin_assets(), which needs updating if this changes.
wp_enqueue_style( 'imgareaselect' );
wp_plupload_default_settings();
diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php
index 7b93f6a5c1f5d..bee8629509b64 100644
--- a/src/wp-includes/script-loader.php
+++ b/src/wp-includes/script-loader.php
@@ -2498,6 +2498,402 @@ function script_concat_settings() {
}
}
+/**
+ * Prints prefetch links for the assets of the screen the user is most likely to open next.
+ *
+ * Runs wherever the next screen can be predicted with confidence, and prefetches only what that
+ * screen is certain to need. Two cases qualify today:
+ *
+ * - The login screen, which is followed by an admin screen. With concatenation disabled that
+ * screen downloads each core script and stylesheet separately, which is what makes an uncached
+ * admin load slower than a concatenated one. Requesting the ones that block its first paint
+ * while the login form is on screen puts them in the HTTP cache during the time the user spends
+ * typing credentials, so the redirect that follows finds them already there.
+ * - The Dashboard and the post list tables, from which the editor is the usual next stop. Only the
+ * editor's stylesheets are prefetched, not its scripts: the scripts run to well over a megabyte
+ * compressed, which is far too much to spend on a screen the user may never open, whereas the
+ * stylesheets are render-blocking and in the same size class as the login screen's own prefetch.
+ *
+ * Handles the current screen has already printed are skipped, so each context only fetches what it
+ * is actually adding.
+ *
+ * These are resources for the *next* navigation rather than for the screen printing them, which is
+ * what `rel="prefetch"` describes. `rel="preload"` would fetch them at the current document's
+ * priority, and browsers warn about preloaded resources the document never uses. A prefetch is
+ * already dispatched at the browser's lowest priority, so it stays out of the way of that screen's
+ * own render-blocking assets without needing `fetchpriority`.
+ *
+ * A prefetched response is reused only for as long as the HTTP cache considers it fresh, the same as
+ * any other cached response. Core does not send caching headers for its static files, so how long
+ * that is depends on the server: an explicit `max-age` or `Expires`, or else a heuristic lifetime
+ * derived from `Last-Modified`. Once the response is stale the next screen still revalidates it,
+ * which saves the download but not the round trip.
+ *
+ * The `as` attribute is still worth setting: it gives the request the same destination the admin
+ * screen will later ask for, which is what lets the prefetched response be reused.
+ *
+ * The admin-wide handles cover every admin screen rather than only the Dashboard, so that part of
+ * the list does not vary with where the login lands. Nothing is printed at all when the login is
+ * not going to lead to an admin screen: on the password reset, registration, logout and
+ * check-your-email flows, on an interim login, or when `redirect_to` points outside this site's
+ * admin.
+ *
+ * Nothing is printed when concatenation is enabled, since `load-scripts.php` and
+ * `load-styles.php` already collapse these handles into a handful of requests.
+ *
+ * @since 7.2.0
+ *
+ * @see wp_preload_resources()
+ *
+ * @global string $action The action that brought the visitor to the login page.
+ * @global bool|string $interim_login Whether interim login modal is being displayed. String 'success'
+ * upon successful login.
+ */
+function wp_prefetch_admin_assets(): void {
+ /*
+ * Deliberately not the $concatenate_scripts global: script_concat_settings() often runs on a
+ * login request before 'login_init' fires — anything registering a script on 'init' is enough
+ * to trigger it — and at that point it evaluates is_admin() as false and settles the global on
+ * false whatever the constant says. What matters here is what the admin screen this login leads
+ * to will do, which is the constant together with the SCRIPT_DEBUG override.
+ */
+ $admin_will_concatenate = ( defined( 'CONCATENATE_SCRIPTS' ) ? CONCATENATE_SCRIPTS : true )
+ && ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG );
+
+ if ( $admin_will_concatenate ) {
+ return;
+ }
+
+ $on_login = ( 'login_head' === current_action() );
+ $script_roots = array();
+ $style_roots = array();
+
+ if ( $on_login ) {
+ /*
+ * Only the login form is followed by an admin screen. The password reset, registration,
+ * logout confirmation and check-your-email flows all render through 'login_head' too, and
+ * none of them leads anywhere these assets are wanted. An interim login re-authenticates
+ * inside a modal on a page that has already loaded them, so it does not need them either.
+ *
+ * The action is the one wp-login.php has already resolved rather than the request parameter,
+ * which it overrides: a `key` switches to the password reset form and `checkemail` to the
+ * check-your-email message, while an action it does not recognize falls back to the login form.
+ */
+ global $action, $interim_login;
+
+ if ( 'login' !== $action || $interim_login ) {
+ return;
+ }
+
+ /*
+ * Resolve where the login is going to land, the same way wp-login.php will: `redirect_to`
+ * when one was given, and the admin otherwise. wp_validate_redirect() mirrors what
+ * wp_safe_redirect() does with a value pointing off-host, which is to fall back to the admin.
+ */
+ $next_screen = admin_url();
+
+ if ( isset( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) {
+ $next_screen = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ), admin_url() );
+ }
+
+ /*
+ * When the login lands somewhere other than the admin, such as the front end or a plugin's
+ * own screen, none of these assets are wanted.
+ */
+ $admin_path = (string) wp_parse_url( admin_url(), PHP_URL_PATH );
+
+ if ( '' === $admin_path || ! str_starts_with( (string) wp_parse_url( $next_screen, PHP_URL_PATH ), $admin_path ) ) {
+ return;
+ }
+
+ /*
+ * Nor are they when it lands in the admin of another host. wp_validate_redirect() accepts any
+ * host in 'allowed_redirect_hosts', such as another site on a multisite network, and that
+ * admin would request its assets from its own host rather than from this one. A relative
+ * `redirect_to` stays on this host.
+ */
+ $next_screen_host = wp_parse_url( $next_screen, PHP_URL_HOST );
+
+ if (
+ is_string( $next_screen_host ) &&
+ (
+ strtolower( $next_screen_host ) !== strtolower( (string) wp_parse_url( admin_url(), PHP_URL_HOST ) ) ||
+ wp_parse_url( $next_screen, PHP_URL_PORT ) !== wp_parse_url( admin_url(), PHP_URL_PORT )
+ )
+ ) {
+ return;
+ }
+ } else {
+ /*
+ * From the Dashboard and the post list tables, the editor is the usual next stop. Anywhere
+ * else in the admin there is no destination worth guessing at.
+ */
+ $screen = get_current_screen();
+
+ if ( ! $screen instanceof WP_Screen || ! in_array( $screen->base, array( 'dashboard', 'edit' ), true ) ) {
+ return;
+ }
+
+ $post_type = ( 'edit' === $screen->base && $screen->post_type ) ? $screen->post_type : 'post';
+ $post_type_object = get_post_type_object( $post_type );
+
+ if ( ! $post_type_object instanceof WP_Post_Type ) {
+ return;
+ }
+
+ /*
+ * A user who cannot create this post type will never reach the editor from here, and a post
+ * type still using the classic editor would not load any of these stylesheets.
+ */
+ if (
+ ! current_user_can( $post_type_object->cap->create_posts ) ||
+ ! use_block_editor_for_post_type( $post_type )
+ ) {
+ return;
+ }
+
+ $next_screen = add_query_arg( 'post_type', $post_type, admin_url( 'post-new.php' ) );
+ }
+
+ if ( $on_login ) {
+ /*
+ * The handles that block rendering on every admin screen: the stylesheets, and the scripts
+ * printed in the head. These are what stand between the redirect and the first paint, so
+ * they are what is worth having in the cache already. Every handle these expand to loads on
+ * all admin screens, not just the one the login happens to land on, so the list does not depend
+ * on the destination. Screen-specific handles are deliberately left out: `site-health`
+ * blocks rendering on the Dashboard but loads nowhere else.
+ *
+ * Scripts printed in the footer are left out even though they block DOMContentLoaded, since
+ * they do not hold back the first paint. They are also where the bulk of the admin's bytes
+ * are, largely the command palette's dependencies, and a prefetch of them still in flight
+ * when the login form is submitted competes with the admin screen's own render-blocking
+ * stylesheets and delays its first paint on a slow connection.
+ *
+ * These are roots rather than the full set: everything they depend on is pulled in with them
+ * below, so the set follows the dependencies declared in wp_default_scripts() and
+ * wp_default_styles(). `jquery` stands for `jquery-core` and `jquery-migrate`, and `wp-admin`
+ * for the admin's own stylesheets, which is what the `colors` handle enqueued on every admin
+ * screen depends on. Aliases like these have no source of their own, so only what they expand
+ * to is prefetched. `colors` itself is left out, since the color scheme is a per-user setting
+ * and the user is not known yet.
+ *
+ * Each root mirrors an enqueue elsewhere, which carries a note pointing back here: `common`
+ * (for `jquery`) in wp-admin/admin.php, `colors` (for `wp-admin` and `buttons`) and `utils` in
+ * wp-admin/admin-header.php, `admin-bar` in WP_Admin_Bar::initialize(), `wp-auth-check` in
+ * wp_auth_check_load(), and `wp-commands` in wp_enqueue_command_palette_assets().
+ */
+ $script_roots = array(
+ 'jquery',
+ 'utils',
+ );
+
+ $style_roots = array(
+ 'wp-admin',
+ 'buttons',
+ 'admin-bar',
+ 'wp-auth-check',
+ 'wp-commands',
+ );
+ }
+
+ /*
+ * post-new.php always opens the editor, while post.php also handles trashing, restoring and
+ * bulk edits, so it counts only when it is editing.
+ */
+ $next_screen_file = basename( (string) wp_parse_url( $next_screen, PHP_URL_PATH ) );
+ $next_screen_query = array();
+ wp_parse_str( (string) wp_parse_url( $next_screen, PHP_URL_QUERY ), $next_screen_query );
+
+ $next_screen_is_block_editor = 'post-new.php' === $next_screen_file
+ || ( 'post.php' === $next_screen_file && 'edit' === ( $next_screen_query['action'] ?? '' ) );
+
+ if ( $next_screen_is_block_editor ) {
+ /*
+ * Roots as well, expanded along with any from the login screen. `wp-edit-post` alone accounts
+ * for most of the editor chrome, including the block editor's content and reset styles by way
+ * of `wp-edit-blocks`; the rest cover the block directory, the format library, the classic
+ * editor's buttons and the media modal.
+ *
+ * Each root mirrors an enqueue elsewhere, which carries a note pointing back here:
+ * `wp-edit-post` in wp-admin/edit-form-blocks.php, `wp-block-directory` in
+ * wp_enqueue_editor_block_directory_assets(), `wp-format-library` in
+ * wp_enqueue_editor_format_library_assets(), `editor-buttons` in
+ * _WP_Editors::enqueue_default_editor(), and `media-views` and `imgareaselect` in
+ * wp_enqueue_media().
+ */
+ $style_roots = array_merge(
+ $style_roots,
+ array(
+ 'wp-edit-post',
+ 'wp-block-directory',
+ 'wp-format-library',
+ 'editor-buttons',
+ 'media-views',
+ 'imgareaselect',
+ )
+ );
+ }
+
+ $resources = array();
+
+ foreach (
+ array(
+ 'script' => array( wp_scripts(), $script_roots ),
+ 'style' => array( wp_styles(), $style_roots ),
+ )
+ as $as => list( $dependencies, $queue )
+ ) {
+ /*
+ * Expand the roots to include everything they depend on, roots first. A handle that is not
+ * registered is dropped along with its dependencies.
+ */
+ $handles = array();
+
+ while ( $queue ) {
+ $handle = array_shift( $queue );
+
+ if ( isset( $handles[ $handle ] ) || ! isset( $dependencies->registered[ $handle ] ) ) {
+ continue;
+ }
+
+ $handles[ $handle ] = true;
+
+ foreach ( $dependencies->registered[ $handle ]->deps as $dependency ) {
+ if ( is_string( $dependency ) && '' !== $dependency && ! isset( $handles[ $dependency ] ) ) {
+ $queue[] = $dependency;
+ }
+ }
+ }
+
+ foreach ( array_keys( $handles ) as $handle ) {
+ /*
+ * Whichever screen this is running on shares some of these handles and has already
+ * printed them by the time this runs, so the browser is fetching them anyway.
+ * Prefetching them again would only add markup.
+ */
+ if ( in_array( $handle, $dependencies->done, true ) ) {
+ continue;
+ }
+
+ /*
+ * The URLs come from the same methods WP_Scripts::do_item() and WP_Styles::do_item()
+ * use for the tags they print, so they match what the next screen will request.
+ */
+ if ( $dependencies instanceof WP_Styles ) {
+ $src = $dependencies->registered[ $handle ]->src;
+ $urls = array();
+
+ // A handle that only aliases other handles has no stylesheet of its own.
+ if ( is_string( $src ) && '' !== $src ) {
+ $urls[] = $dependencies->_css_href( $src, $dependencies->registered[ $handle ]->ver, $handle );
+ }
+
+ $rtl_href = $dependencies->get_rtl_href( $handle );
+
+ if ( null !== $rtl_href ) {
+ if ( 'replace' === $dependencies->get_data( $handle, 'rtl' ) ) {
+ $urls = array( $rtl_href );
+ } else {
+ $urls[] = $rtl_href;
+ }
+ }
+
+ /*
+ * Unlike a script's URL, a stylesheet's comes back escaped for an HTML attribute, with
+ * `&` as `&`. Decode it so the filter sees plain URLs throughout, and so a
+ * callback appending the plain form of one is collapsed with it. The URLs are escaped
+ * again when printed.
+ */
+ $urls = array_map( array( 'WP_HTML_Decoder', 'decode_attribute' ), $urls );
+ } else {
+ $urls = array( $dependencies->get_src( $handle ) );
+ }
+
+ foreach ( array_filter( $urls ) as $url ) {
+ $resources[] = array(
+ 'href' => $url,
+ 'as' => $as,
+ );
+ }
+ }
+ }
+
+ /**
+ * Filters the assets prefetched for the screen the user is expected to open next.
+ *
+ * Fires on any screen from which the next one can be predicted, so `$next_screen` is what
+ * distinguishes the contexts: the login screen passes the URL it is about to redirect to, and
+ * the Dashboard and post list tables pass the editor they expect the user to open.
+ *
+ * Only the `href` and `as` attributes below are printed; any other key is ignored. Resources
+ * sharing an `href` are collapsed to the first of them, so a callback may append without
+ * checking what is already there. Returning an empty array turns the prefetching off.
+ *
+ * On the login screen the URLs are built before the user is authenticated and outside the
+ * admin, so there is no current user and `is_admin()` is false. A {@see 'script_loader_src'}
+ * or {@see 'style_loader_src'} callback that depends on either can produce a URL the admin
+ * screen will not request, which wastes the prefetch; a callback on this filter can correct it.
+ *
+ * @since 7.2.0
+ *
+ * @param array $resources {
+ * Array of resources and their attributes to prefetch.
+ *
+ * @type array ...$0 {
+ * Array of resource attributes.
+ *
+ * @type string $href URL to prefetch. Required.
+ * @type string $as How the browser should treat the resource
+ * (`script`, `style`, `image`, `document`, etc). Required.
+ * }
+ * }
+ * @param string $next_screen URL of the screen the assets are being prefetched for. Always
+ * points into the admin, since nothing is prefetched otherwise.
+ * From the login screen this is the redirect target, already run
+ * through wp_validate_redirect() with the admin as the fallback,
+ * and it may be relative: it is the value as wp_safe_redirect()
+ * will receive it, so a request-supplied path is passed through
+ * unchanged and only the fallback is a full URL.
+ */
+ $resources = apply_filters( 'prefetch_admin_assets', $resources, $next_screen );
+
+ if ( ! is_array( $resources ) ) {
+ return;
+ }
+
+ $unique_resources = array();
+
+ // Parse the complete resource list and extract unique resources.
+ foreach ( $resources as $resource ) {
+ if ( ! is_array( $resource ) ) {
+ continue;
+ }
+
+ $href = $resource['href'] ?? '';
+ $as = $resource['as'] ?? '';
+
+ if ( ! is_string( $href ) || '' === $href || ! is_string( $as ) || '' === $as ) {
+ continue;
+ }
+
+ if ( isset( $unique_resources[ $href ] ) ) {
+ continue;
+ }
+
+ $unique_resources[ $href ] = $as;
+ }
+
+ // Build and output the HTML for each unique resource.
+ foreach ( $unique_resources as $href => $as ) {
+ printf(
+ "\n",
+ esc_url( $href ),
+ esc_attr( $as )
+ );
+ }
+}
+
/**
* Handles the enqueueing of block scripts and styles that are common to both
* the editor and the front-end.
@@ -2916,6 +3312,7 @@ function enqueue_editor_block_styles_assets() {
*/
function wp_enqueue_editor_block_directory_assets() {
wp_enqueue_script( 'wp-block-directory' );
+ // Prefetched for the block editor by wp_prefetch_admin_assets(), which needs updating if this changes.
wp_enqueue_style( 'wp-block-directory' );
}
@@ -2926,6 +3323,7 @@ function wp_enqueue_editor_block_directory_assets() {
*/
function wp_enqueue_editor_format_library_assets() {
wp_enqueue_script( 'wp-format-library' );
+ // Prefetched for the block editor by wp_prefetch_admin_assets(), which needs updating if this changes.
wp_enqueue_style( 'wp-format-library' );
}
@@ -3607,6 +4005,7 @@ function wp_enqueue_command_palette_assets() {
}
wp_enqueue_script( 'wp-commands' );
+ // Prefetched from the login screen by wp_prefetch_admin_assets(), which needs updating if this changes.
wp_enqueue_style( 'wp-commands' );
wp_enqueue_script( 'wp-core-commands' );
diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php
new file mode 100644
index 0000000000000..ce16bb9bdc9af
--- /dev/null
+++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php
@@ -0,0 +1,704 @@
+markTestSkipped( 'SCRIPT_DEBUG is on, which turns off concatenation.' );
+ }
+
+ $this->assertSame( array(), $this->get_prefetched_on_login() );
+ }
+
+ /**
+ * Tests that `SCRIPT_DEBUG` turns off concatenation, and so turns on prefetching, even when
+ * `CONCATENATE_SCRIPTS` is on.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_script_debug_overrides_concatenation(): void {
+ define( 'CONCATENATE_SCRIPTS', true );
+
+ if ( ! SCRIPT_DEBUG ) {
+ $this->markTestSkipped( 'SCRIPT_DEBUG is off.' );
+ }
+
+ $this->assertNotSame( array(), $this->get_prefetched_on_login() );
+ }
+
+ /**
+ * Tests what the login screen prefetches for the admin: the render-blocking head scripts and the
+ * admin-wide stylesheets, but not the editor's stylesheets or the per-user color scheme.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_login_prefetches_render_blocking_admin_assets(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ $links = $this->get_prefetched_on_login();
+
+ $this->assertPrefetched( $links, 'script', '#/wp-includes/js/jquery/jquery(\.min)?\.js#' );
+ $this->assertPrefetched( $links, 'script', '#/wp-includes/js/jquery/jquery-migrate(\.min)?\.js#' );
+ $this->assertPrefetched( $links, 'script', '#/wp-includes/js/utils(\.min)?\.js#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dashicons(\.min)?\.css#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-includes/css/buttons(\.min)?\.css#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-includes/css/admin-bar(\.min)?\.css#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/commands/style(\.min)?\.css#' );
+
+ // Footer scripts are not prefetched.
+ $this->assertNotPrefetched( $links, '#/wp-admin/js/common(\.min)?\.js#' );
+ $this->assertNotPrefetched( $links, '#/hoverIntent(\.min)?\.js#' );
+
+ // Neither is the color scheme, which depends on the user.
+ $this->assertNotPrefetched( $links, '#/wp-admin/css/colors/#' );
+
+ // Nor the editor, which the login is not leading to.
+ $this->assertNotPrefetched( $links, '#/wp-includes/css/dist/edit-post/#' );
+ }
+
+ /**
+ * Tests that a login leading to the block editor also prefetches the editor's stylesheets.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ *
+ * @dataProvider data_editor_destinations
+ *
+ * @param string $redirect_to Where the login redirects to.
+ * @param bool $is_editor Whether that is the block editor.
+ */
+ public function test_login_prefetches_editor_assets_for_editor_destination( string $redirect_to, bool $is_editor ): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ $links = $this->get_prefetched_on_login( array( 'redirect_to' => $redirect_to ) );
+
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+
+ if ( $is_editor ) {
+ $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/edit-post/style(\.min)?\.css#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/block-editor/content(\.min)?\.css#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-includes/css/media-views(\.min)?\.css#' );
+ } else {
+ $this->assertNotPrefetched( $links, '#/wp-includes/css/dist/edit-post/#' );
+ }
+ }
+
+ /**
+ * Data provider for {@see self::test_login_prefetches_editor_assets_for_editor_destination()}.
+ *
+ * @return array
+ */
+ public function data_editor_destinations(): array {
+ return array(
+ 'Dashboard' => array( '/wp-admin/', false ),
+ 'new post' => array( '/wp-admin/post-new.php', true ),
+ 'new page' => array( '/wp-admin/post-new.php?post_type=page', true ),
+ 'editing a post' => array( '/wp-admin/post.php?post=1&action=edit', true ),
+ 'trashing a post' => array( '/wp-admin/post.php?post=1&action=trash', false ),
+ 'post list' => array( '/wp-admin/edit.php', false ),
+ );
+ }
+
+ /**
+ * Tests that an absolute `redirect_to` pointing at this site's editor also prefetches the
+ * editor's stylesheets.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_login_prefetches_editor_assets_for_absolute_editor_url(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ $links = $this->get_prefetched_on_login( array( 'redirect_to' => admin_url( 'post-new.php' ) ) );
+
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/edit-post/style(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that nothing is prefetched from login screens that do not lead to the admin.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ *
+ * @dataProvider data_login_requests_not_leading_to_admin
+ *
+ * @param array $request Request parameters of the login screen.
+ * @param string|null $action Action as resolved by wp-login.php.
+ * @param bool $interim_login Whether the interim login modal is displayed.
+ */
+ public function test_login_prints_nothing_when_not_leading_to_admin( array $request, ?string $action, bool $interim_login ): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ $this->assertSame( array(), $this->get_prefetched_on_login( $request, $action, $interim_login ) );
+ }
+
+ /**
+ * Data provider for {@see self::test_login_prints_nothing_when_not_leading_to_admin()}.
+ *
+ * The action is the one wp-login.php resolves for the request.
+ *
+ * @return array, 1: string|null, 2: bool }>
+ */
+ public function data_login_requests_not_leading_to_admin(): array {
+ return array(
+ 'lost password' => array( array( 'action' => 'lostpassword' ), 'lostpassword', false ),
+ 'registration' => array( array( 'action' => 'register' ), 'register', false ),
+ 'logout' => array( array( 'action' => 'logout' ), 'logout', false ),
+ 'password reset key' => array(
+ array(
+ 'key' => 'abc',
+ 'login' => 'admin',
+ ),
+ 'resetpass',
+ false,
+ ),
+ 'check email' => array( array( 'checkemail' => 'confirm' ), 'checkemail', false ),
+ 'check email after register' => array( array( 'checkemail' => 'registered' ), 'checkemail', false ),
+ 'interim login' => array( array( 'interim-login' => '1' ), 'login', true ),
+ 'login_head fired by plugin' => array( array(), null, false ),
+ 'front end redirect' => array( array( 'redirect_to' => '/hello-world/' ), 'login', false ),
+ 'lookalike admin path' => array( array( 'redirect_to' => '/wp-admin-lookalike/' ), 'login', false ),
+ );
+ }
+
+ /**
+ * Tests that nothing is prefetched when an absolute `redirect_to` points at this site's front end.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_login_prints_nothing_for_absolute_front_end_redirect(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ $this->assertSame( array(), $this->get_prefetched_on_login( array( 'redirect_to' => home_url( '/hello-world/' ) ) ) );
+ }
+
+ /**
+ * Tests that the action wp-login.php resolved is used rather than the request parameter, so an
+ * action wp-login.php does not recognize, which it treats as the login form, still prefetches.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_login_uses_action_resolved_by_wp_login(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ $links = $this->get_prefetched_on_login( array( 'action' => 'unrecognized' ), 'login' );
+
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that a `redirect_to` pointing off-site falls back to the admin, as wp_safe_redirect() does.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_login_off_site_redirect_falls_back_to_admin(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ $filter = new MockAction();
+ add_filter( 'prefetch_admin_assets', array( $filter, 'filter' ), 10, 2 );
+
+ $links = $this->get_prefetched_on_login( array( 'redirect_to' => 'https://elsewhere.example.com/wp-admin/post-new.php' ) );
+
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ $this->assertNotPrefetched( $links, '#/wp-includes/css/dist/edit-post/#' );
+ $this->assertSame( admin_url(), $filter->get_args()[0][1] );
+ }
+
+ /**
+ * Tests that nothing is prefetched when the login lands in the admin of another allowed host,
+ * such as another site on a multisite network, which would request its assets from its own host.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_login_prints_nothing_for_admin_on_another_allowed_host(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ add_filter(
+ 'allowed_redirect_hosts',
+ static function ( array $hosts ): array {
+ $hosts[] = 'another.example.net';
+ return $hosts;
+ }
+ );
+
+ $this->assertSame(
+ array(),
+ $this->get_prefetched_on_login( array( 'redirect_to' => 'http://another.example.net/wp-admin/post-new.php' ) )
+ );
+ }
+
+ /**
+ * Tests that nothing is prefetched when the login lands in an admin on this host but another port,
+ * which wp_validate_redirect() allows since it compares only the host.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_login_prints_nothing_for_admin_on_another_port(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ $scheme = (string) wp_parse_url( admin_url(), PHP_URL_SCHEME );
+ $host = (string) wp_parse_url( admin_url(), PHP_URL_HOST );
+ $port = (int) wp_parse_url( admin_url(), PHP_URL_PORT );
+ $path = (string) wp_parse_url( admin_url(), PHP_URL_PATH );
+
+ // Any port other than the admin's own, which is the scheme's default when none is given.
+ $other_port = ( $port ? $port : ( 'https' === $scheme ? 443 : 80 ) ) + 1;
+
+ $this->assertSame(
+ array(),
+ $this->get_prefetched_on_login( array( 'redirect_to' => "{$scheme}://{$host}:{$other_port}{$path}" ) )
+ );
+ }
+
+ /**
+ * Tests that the Dashboard and the post list tables prefetch only the editor's stylesheets.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ *
+ * @dataProvider data_admin_screens_leading_to_editor
+ *
+ * @param string $screen Screen ID.
+ * @param string $post_type Post type of the editor expected to be prefetched for.
+ */
+ public function test_admin_screen_prefetches_editor_assets( string $screen, string $post_type ): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+
+ $filter = new MockAction();
+ add_filter( 'prefetch_admin_assets', array( $filter, 'filter' ), 10, 2 );
+
+ $links = $this->get_prefetched_on_admin_screen( $screen );
+
+ $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/edit-post/style(\.min)?\.css#' );
+ $this->assertSame( array(), $this->get_hrefs( $links, 'script' ), 'No scripts should be prefetched for the editor.' );
+ $this->assertSame( add_query_arg( 'post_type', $post_type, admin_url( 'post-new.php' ) ), $filter->get_args()[0][1] );
+ }
+
+ /**
+ * Data provider for {@see self::test_admin_screen_prefetches_editor_assets()}.
+ *
+ * @return array
+ */
+ public function data_admin_screens_leading_to_editor(): array {
+ return array(
+ 'Dashboard' => array( 'dashboard', 'post' ),
+ 'Posts list' => array( 'edit', 'post' ),
+ 'Pages list' => array( 'edit-page', 'page' ),
+ );
+ }
+
+ /**
+ * Tests that admin screens other than the site's Dashboard and post list tables prefetch nothing.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ *
+ * @dataProvider data_other_admin_screens
+ *
+ * @param string $screen Screen ID.
+ */
+ public function test_other_admin_screen_prints_nothing( string $screen ): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+
+ $this->assertSame( array(), $this->get_prefetched_on_admin_screen( $screen ) );
+ }
+
+ /**
+ * Data provider for {@see self::test_other_admin_screen_prints_nothing()}.
+ *
+ * @return array
+ */
+ public function data_other_admin_screens(): array {
+ return array(
+ 'Plugins' => array( 'plugins' ),
+ 'Network Admin Dashboard' => array( 'dashboard-network' ),
+ 'User Admin Dashboard' => array( 'dashboard-user' ),
+ );
+ }
+
+ /**
+ * Tests that nothing is prefetched for a user who cannot create posts of the type listed.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_admin_screen_prints_nothing_for_user_who_cannot_create_posts(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );
+
+ $this->assertSame( array(), $this->get_prefetched_on_admin_screen( 'dashboard' ) );
+ }
+
+ /**
+ * Tests that nothing is prefetched for a post type that uses the classic editor.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_admin_screen_prints_nothing_for_classic_editor(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ add_filter( 'use_block_editor_for_post_type', '__return_false' );
+
+ $this->assertSame( array(), $this->get_prefetched_on_admin_screen( 'edit' ) );
+ }
+
+ /**
+ * Tests that assets the current screen has already printed are not prefetched again.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_skips_assets_already_printed(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ wp_styles()->done[] = 'common';
+ wp_scripts()->done[] = 'utils';
+
+ $links = $this->get_prefetched_on_login();
+
+ $this->assertNotPrefetched( $links, '#/wp-admin/css/common(\.min)?\.css#' );
+ $this->assertNotPrefetched( $links, '#/wp-includes/js/utils(\.min)?\.js#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/forms(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that a right-to-left locale prefetches the right-to-left stylesheets.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_prefetches_rtl_stylesheets(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+ wp_styles()->text_direction = 'rtl';
+
+ $links = $this->get_prefetched_on_login();
+
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common-rtl(\.min)?\.css#' );
+ $this->assertNotPrefetched( $links, '#/wp-admin/css/common(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that stylesheet URLs reach the filter unescaped, like script URLs, so that a callback
+ * appending the plain form of one is collapsed with it.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_filter_receives_unescaped_stylesheet_urls(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ // Give a stylesheet a query string of its own, so its URL has an `&` before the version.
+ wp_styles()->registered['common']->src = '/wp-admin/css/common.css?color=blue';
+
+ $common_href = null;
+ add_filter(
+ 'prefetch_admin_assets',
+ static function ( array $resources ) use ( &$common_href ): array {
+ foreach ( $resources as $resource ) {
+ if (
+ is_array( $resource ) &&
+ isset( $resource['href'] ) &&
+ is_string( $resource['href'] ) &&
+ str_contains( $resource['href'], '/wp-admin/css/common.css' )
+ ) {
+ $common_href = $resource['href'];
+
+ // Append the plain form of the same URL, as a callback building it itself would.
+ $resources[] = array(
+ 'href' => str_replace( '&', '&', $resource['href'] ),
+ 'as' => 'style',
+ );
+ }
+ }
+ return $resources;
+ }
+ );
+
+ $links = $this->get_prefetched_on_login();
+
+ $this->assertIsString( $common_href );
+ $this->assertStringContainsString( '/wp-admin/css/common.css?color=blue&ver=', $common_href );
+ $this->assertStringNotContainsString( '&', $common_href );
+
+ $common_links = array_filter(
+ $links,
+ static function ( array $link ): bool {
+ return str_contains( $link['href'], '/wp-admin/css/common.css' );
+ }
+ );
+ $this->assertCount( 1, $common_links, 'The plain form of the URL should be collapsed with it.' );
+ }
+
+ /**
+ * Tests that the filter can add, replace and remove resources, and that its result is sanitized.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_filter_result_is_deduplicated_and_sanitized(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ add_filter(
+ 'prefetch_admin_assets',
+ static function (): array {
+ return array(
+ array(
+ 'href' => 'https://example.com/first.js',
+ 'as' => 'script',
+ ),
+ array(
+ 'href' => 'https://example.com/first.js',
+ 'as' => 'style',
+ ),
+ array(
+ 'href' => 'https://example.com/image.png',
+ 'as' => 'image',
+ ),
+ array( 'href' => 'https://example.com/no-destination.js' ),
+ array( 'as' => 'script' ),
+ array(
+ 'href' => '',
+ 'as' => 'script',
+ ),
+ 'not an array',
+ );
+ }
+ );
+
+ $this->assertSame(
+ array(
+ array(
+ 'href' => 'https://example.com/first.js',
+ 'as' => 'script',
+ ),
+ array(
+ 'href' => 'https://example.com/image.png',
+ 'as' => 'image',
+ ),
+ ),
+ $this->get_prefetched_on_login()
+ );
+ }
+
+ /**
+ * Tests that the filter can turn prefetching off.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ *
+ * @dataProvider data_filter_turning_off
+ *
+ * @param mixed $filtered Value the filter returns.
+ */
+ public function test_filter_can_turn_off_prefetching( $filtered ): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ add_filter(
+ 'prefetch_admin_assets',
+ static function () use ( $filtered ) {
+ return $filtered;
+ }
+ );
+
+ $this->assertSame( array(), $this->get_prefetched_on_login() );
+ }
+
+ /**
+ * Data provider for {@see self::test_filter_can_turn_off_prefetching()}.
+ *
+ * @return array
+ */
+ public function data_filter_turning_off(): array {
+ return array(
+ 'empty array' => array( array() ),
+ 'not an array' => array( null ),
+ );
+ }
+
+ /**
+ * Runs the login screen's prefetching and returns the links it printed.
+ *
+ * @param array $request Request parameters of the login screen.
+ * @param string|null $action Action as resolved by wp-login.php, or null for
+ * `login_head` fired by a plugin outside of it.
+ * @param bool $interim_login Whether wp-login.php is displaying the interim
+ * login modal.
+ * @return list Prefetch links in the order printed.
+ */
+ private function get_prefetched_on_login( array $request = array(), ?string $action = 'login', bool $interim_login = false ): array {
+ $_REQUEST = $request;
+
+ $GLOBALS['action'] = $action;
+ $GLOBALS['interim_login'] = $interim_login;
+
+ remove_all_actions( 'login_head' );
+ add_action( 'login_head', 'wp_prefetch_admin_assets' );
+
+ return $this->parse_prefetch_links( get_echo( 'do_action', array( 'login_head' ) ) );
+ }
+
+ /**
+ * Runs an admin screen's prefetching and returns the links it printed.
+ *
+ * @param string $screen Screen ID.
+ * @return list Prefetch links in the order printed.
+ */
+ private function get_prefetched_on_admin_screen( string $screen ): array {
+ set_current_screen( $screen );
+
+ remove_all_actions( 'admin_head' );
+ add_action( 'admin_head', 'wp_prefetch_admin_assets' );
+
+ return $this->parse_prefetch_links( get_echo( 'do_action', array( 'admin_head' ) ) );
+ }
+
+ /**
+ * Parses the prefetch links out of printed markup.
+ *
+ * @param string $html Printed markup.
+ * @return list Prefetch links in document order.
+ */
+ private function parse_prefetch_links( string $html ): array {
+ $links = array();
+ $processor = new WP_HTML_Tag_Processor( $html );
+
+ while ( $processor->next_tag( 'LINK' ) ) {
+ if ( 'prefetch' !== $processor->get_attribute( 'rel' ) ) {
+ continue;
+ }
+
+ $links[] = array(
+ 'href' => (string) $processor->get_attribute( 'href' ),
+ 'as' => (string) $processor->get_attribute( 'as' ),
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * Gets the URLs prefetched with the given `as` value.
+ *
+ * @param list $links Prefetch links.
+ * @param string $as_value Value of the `as` attribute.
+ * @return list URLs.
+ */
+ private function get_hrefs( array $links, string $as_value ): array {
+ $hrefs = array();
+
+ foreach ( $links as $link ) {
+ if ( $as_value === $link['as'] ) {
+ $hrefs[] = $link['href'];
+ }
+ }
+
+ return $hrefs;
+ }
+
+ /**
+ * Asserts that a URL matching the pattern is prefetched with the given `as` value.
+ *
+ * @param list $links Prefetch links.
+ * @param string $as_value Expected value of the `as` attribute.
+ * @param non-empty-string $pattern Regular expression the URL must match.
+ */
+ private function assertPrefetched( array $links, string $as_value, string $pattern ): void {
+ $this->assertNotEmpty(
+ preg_grep( $pattern, $this->get_hrefs( $links, $as_value ) ),
+ "Expected a prefetch link with as='{$as_value}' matching {$pattern}."
+ );
+ }
+
+ /**
+ * Asserts that no URL matching the pattern is prefetched.
+ *
+ * @param list $links Prefetch links.
+ * @param non-empty-string $pattern Regular expression the URL must not match.
+ */
+ private function assertNotPrefetched( array $links, string $pattern ): void {
+ $this->assertEmpty(
+ preg_grep( $pattern, array_column( $links, 'href' ) ),
+ "Expected no prefetch link matching {$pattern}."
+ );
+ }
+}
diff --git a/tests/phpunit/tests/dependencies/wpScripts/getSrc.php b/tests/phpunit/tests/dependencies/wpScripts/getSrc.php
new file mode 100644
index 0000000000000..1f7ad03d50bef
--- /dev/null
+++ b/tests/phpunit/tests/dependencies/wpScripts/getSrc.php
@@ -0,0 +1,177 @@
+scripts = new WP_Scripts();
+ $this->scripts->base_url = 'http://example.org';
+ $this->scripts->content_url = '/wp-content';
+ $this->scripts->default_version = '7.2';
+ }
+
+ /**
+ * Tests that the URL is built from the source, the base URL and the version.
+ *
+ * @ticket 57548
+ *
+ * @dataProvider data_builds_url
+ *
+ * @param string $src Source the script is registered with.
+ * @param string|false|null $ver Version the script is registered with.
+ * @param string $expected Expected URL.
+ */
+ public function test_builds_url( string $src, $ver, string $expected ): void {
+ $this->scripts->add( 'test', $src, array(), $ver );
+
+ $this->assertSame( $expected, $this->scripts->get_src( 'test' ) );
+ }
+
+ /**
+ * Data provider for {@see self::test_builds_url()}.
+ *
+ * @return array
+ */
+ public function data_builds_url(): array {
+ return array(
+ 'relative source, default version' => array( '/wp-includes/js/test.js', false, 'http://example.org/wp-includes/js/test.js?ver=7.2' ),
+ 'relative source, explicit version' => array( '/wp-includes/js/test.js', '1.0', 'http://example.org/wp-includes/js/test.js?ver=1.0' ),
+ 'relative source, no version' => array( '/wp-includes/js/test.js', null, 'http://example.org/wp-includes/js/test.js' ),
+ 'absolute source' => array( 'https://cdn.example.com/test.js', '1.0', 'https://cdn.example.com/test.js?ver=1.0' ),
+ 'protocol-relative source' => array( '//cdn.example.com/test.js', '1.0', '//cdn.example.com/test.js?ver=1.0' ),
+ 'source under the content URL' => array( '/wp-content/plugins/test/test.js', '1.0', '/wp-content/plugins/test/test.js?ver=1.0' ),
+ 'source with a query string' => array( 'https://cdn.example.com/test.js?a=1', '1.0', 'https://cdn.example.com/test.js?a=1&ver=1.0' ),
+ 'source with a fragment' => array( 'https://cdn.example.com/test.js#frag', '1.0', 'https://cdn.example.com/test.js?ver=1.0#frag' ),
+ 'source with a fragment and no version' => array( 'https://cdn.example.com/test.js#frag', null, 'https://cdn.example.com/test.js#frag' ),
+ 'version needing to be encoded' => array( 'https://cdn.example.com/test.js', '1.0 beta', 'https://cdn.example.com/test.js?ver=1.0%20beta' ),
+ );
+ }
+
+ /**
+ * Tests that arguments added to the handle are appended after the version.
+ *
+ * @ticket 57548
+ */
+ public function test_appends_handle_args(): void {
+ $this->scripts->add( 'test', 'https://cdn.example.com/test.js#frag', array(), '1.0' );
+ $this->scripts->all_deps( 'test?a=1&b=2' );
+
+ $this->assertSame( 'https://cdn.example.com/test.js?ver=1.0&a=1&b=2#frag', $this->scripts->get_src( 'test' ) );
+ }
+
+ /**
+ * Tests that an empty string is returned for a handle that is not registered.
+ *
+ * @ticket 57548
+ */
+ public function test_returns_empty_string_for_unregistered_handle(): void {
+ $this->assertSame( '', $this->scripts->get_src( 'unregistered' ) );
+ }
+
+ /**
+ * Tests that an empty string is returned for a handle that only aliases other handles.
+ *
+ * @ticket 57548
+ */
+ public function test_returns_empty_string_for_alias(): void {
+ $this->scripts->add( 'dependency', '/wp-includes/js/dependency.js' );
+ $this->scripts->add( 'alias', false, array( 'dependency' ) );
+
+ $this->assertSame( '', $this->scripts->get_src( 'alias' ) );
+ }
+
+ /**
+ * Tests that the URL is passed through the {@see 'script_loader_src'} filter along with the handle.
+ *
+ * @ticket 57548
+ */
+ public function test_applies_script_loader_src_filter(): void {
+ $this->scripts->add( 'test', '/wp-includes/js/test.js', array(), '1.0' );
+
+ $filter = new MockAction();
+ add_filter( 'script_loader_src', array( $filter, 'filter' ), 10, 2 );
+ add_filter(
+ 'script_loader_src',
+ static function ( string $src, string $handle ): string {
+ return 'test' === $handle ? str_replace( 'example.org', 'cdn.example.com', $src ) : $src;
+ },
+ 20,
+ 2
+ );
+
+ $this->assertSame( 'http://cdn.example.com/wp-includes/js/test.js?ver=1.0', $this->scripts->get_src( 'test' ) );
+ $this->assertSame(
+ array( 'http://example.org/wp-includes/js/test.js?ver=1.0', 'test' ),
+ $filter->get_args()[0]
+ );
+ }
+
+ /**
+ * Tests that an empty string is returned when the filter removes the URL.
+ *
+ * @ticket 57548
+ *
+ * @dataProvider data_filtered_away
+ *
+ * @param mixed $filtered Value the {@see 'script_loader_src'} filter returns.
+ */
+ public function test_returns_empty_string_when_filtered_away( $filtered ): void {
+ $this->scripts->add( 'test', '/wp-includes/js/test.js' );
+
+ add_filter(
+ 'script_loader_src',
+ static function () use ( $filtered ) {
+ return $filtered;
+ }
+ );
+
+ $this->assertSame( '', $this->scripts->get_src( 'test' ) );
+ }
+
+ /**
+ * Data provider for {@see self::test_returns_empty_string_when_filtered_away()}.
+ *
+ * @return array
+ */
+ public function data_filtered_away(): array {
+ return array(
+ 'empty string' => array( '' ),
+ 'false' => array( false ),
+ 'null' => array( null ),
+ );
+ }
+
+ /**
+ * Tests that the URL matches the one {@see WP_Scripts::do_item()} prints.
+ *
+ * @ticket 57548
+ */
+ public function test_matches_printed_src(): void {
+ $this->scripts->add( 'test', 'https://cdn.example.com/test.js#frag', array(), '1.0' );
+ $this->scripts->all_deps( 'test?a=1&b=2' );
+
+ $processor = new WP_HTML_Tag_Processor( get_echo( array( $this->scripts, 'do_item' ), array( 'test' ) ) );
+ $this->assertTrue( $processor->next_tag( 'SCRIPT' ) );
+
+ $this->assertSame( $this->scripts->get_src( 'test' ), $processor->get_attribute( 'src' ) );
+ }
+}
diff --git a/tests/phpunit/tests/dependencies/wpStyles/getRtlHref.php b/tests/phpunit/tests/dependencies/wpStyles/getRtlHref.php
new file mode 100644
index 0000000000000..02869fc763ce6
--- /dev/null
+++ b/tests/phpunit/tests/dependencies/wpStyles/getRtlHref.php
@@ -0,0 +1,177 @@
+styles = new WP_Styles();
+ $this->styles->base_url = 'http://example.org';
+ $this->styles->default_version = '7.2';
+ $this->styles->text_direction = 'rtl';
+ }
+
+ /**
+ * Tests the URL of the right-to-left stylesheet for each form of `rtl` data.
+ *
+ * @ticket 57548
+ *
+ * @dataProvider data_builds_rtl_url
+ *
+ * @param string $src Source the style is registered with.
+ * @param string|false $ver Version the style is registered with.
+ * @param array $data Data added to the style.
+ * @param string $expected Expected URL.
+ */
+ public function test_builds_rtl_url( string $src, $ver, array $data, string $expected ): void {
+ $this->styles->add( 'test', $src, array(), $ver );
+ foreach ( $data as $key => $value ) {
+ $this->styles->add_data( 'test', $key, $value );
+ }
+
+ $this->assertSame( $expected, $this->styles->get_rtl_href( 'test' ) );
+ }
+
+ /**
+ * Data provider for {@see self::test_builds_rtl_url()}.
+ *
+ * @return array, 3: string }>
+ */
+ public function data_builds_rtl_url(): array {
+ return array(
+ 'rtl true' => array( '/wp-admin/css/test.css', '1.0', array( 'rtl' => true ), 'http://example.org/wp-admin/css/test-rtl.css?ver=1.0' ),
+ 'rtl replace' => array( '/wp-admin/css/test.css', '1.0', array( 'rtl' => 'replace' ), 'http://example.org/wp-admin/css/test-rtl.css?ver=1.0' ),
+ 'rtl true, default version' => array( '/wp-admin/css/test.css', false, array( 'rtl' => true ), 'http://example.org/wp-admin/css/test-rtl.css?ver=7.2' ),
+ 'rtl true, with suffix' => array(
+ '/wp-admin/css/test.min.css',
+ '1.0',
+ array(
+ 'rtl' => true,
+ 'suffix' => '.min',
+ ),
+ 'http://example.org/wp-admin/css/test-rtl.min.css?ver=1.0',
+ ),
+ 'rtl URL' => array( '/wp-admin/css/test.css', '1.0', array( 'rtl' => 'https://cdn.example.com/test-rtl.css' ), 'https://cdn.example.com/test-rtl.css?ver=1.0' ),
+ 'rtl URL with a query string' => array( '/wp-admin/css/test.css', '1.0', array( 'rtl' => 'https://cdn.example.com/test-rtl.css?a=1' ), 'https://cdn.example.com/test-rtl.css?a=1&ver=1.0' ),
+ );
+ }
+
+ /**
+ * Tests that null is returned whenever there is no right-to-left stylesheet to load.
+ *
+ * @ticket 57548
+ *
+ * @dataProvider data_returns_null
+ *
+ * @param 'ltr'|'rtl' $text_direction Text direction of the registry.
+ * @param string|false $src Source the style is registered with, or false to leave it unregistered.
+ * @param mixed $rtl The style's `rtl` data, or null to add none.
+ */
+ public function test_returns_null( string $text_direction, $src, $rtl ): void {
+ $this->styles->text_direction = $text_direction;
+
+ if ( false !== $src ) {
+ $this->styles->add( 'dependency', '/wp-admin/css/dependency.css' );
+ $this->styles->add( 'test', $src, array( 'dependency' ) );
+
+ if ( null !== $rtl ) {
+ $this->styles->add_data( 'test', 'rtl', $rtl );
+ }
+ }
+
+ $this->assertNull( $this->styles->get_rtl_href( 'test' ) );
+ }
+
+ /**
+ * Data provider for {@see self::test_returns_null()}.
+ *
+ * @return array
+ */
+ public function data_returns_null(): array {
+ return array(
+ 'left-to-right text direction' => array( 'ltr', '/wp-admin/css/test.css', true ),
+ 'unregistered handle' => array( 'rtl', false, null ),
+ 'no rtl data' => array( 'rtl', '/wp-admin/css/test.css', null ),
+ 'rtl false' => array( 'rtl', '/wp-admin/css/test.css', false ),
+ 'rtl not a string' => array( 'rtl', '/wp-admin/css/test.css', 1 ),
+ 'alias without a source' => array( 'rtl', '', true ),
+ );
+ }
+
+ /**
+ * Tests that the URL is passed through the {@see 'style_loader_src'} filter with the right-to-left handle.
+ *
+ * @ticket 57548
+ */
+ public function test_applies_style_loader_src_filter(): void {
+ $this->styles->add( 'test', '/wp-admin/css/test.css', array(), '1.0' );
+ $this->styles->add_data( 'test', 'rtl', true );
+
+ $filter = new MockAction();
+ add_filter( 'style_loader_src', array( $filter, 'filter' ), 10, 2 );
+ add_filter(
+ 'style_loader_src',
+ static function ( string $src, string $handle ): string {
+ return 'test-rtl' === $handle ? str_replace( 'example.org', 'cdn.example.com', $src ) : $src;
+ },
+ 20,
+ 2
+ );
+
+ $this->assertSame( 'http://cdn.example.com/wp-admin/css/test-rtl.css?ver=1.0', $this->styles->get_rtl_href( 'test' ) );
+ $this->assertSame(
+ array( 'http://example.org/wp-admin/css/test.css?ver=1.0', 'test-rtl' ),
+ $filter->get_args()[0]
+ );
+ }
+
+ /**
+ * Tests that the URL matches the one {@see WP_Styles::do_item()} prints, whether the right-to-left
+ * stylesheet replaces the left-to-right one or loads alongside it.
+ *
+ * @ticket 57548
+ *
+ * @dataProvider data_matches_printed_href
+ *
+ * @param true|'replace' $rtl The style's `rtl` data.
+ * @param positive-int $expected Number of stylesheets expected to be printed.
+ */
+ public function test_matches_printed_href( $rtl, int $expected ): void {
+ $this->styles->add( 'test', '/wp-admin/css/test.css', array(), '1.0' );
+ $this->styles->add_data( 'test', 'rtl', $rtl );
+
+ $output = get_echo( array( $this->styles, 'do_item' ), array( 'test' ) );
+
+ $this->assertStringContainsString( "id='test-rtl-css' href='{$this->styles->get_rtl_href( 'test' )}'", $output );
+ $this->assertSame( $expected, substr_count( $output, "rel='stylesheet'" ) );
+ }
+
+ /**
+ * Data provider for {@see self::test_matches_printed_href()}.
+ *
+ * @return array
+ */
+ public function data_matches_printed_href(): array {
+ return array(
+ 'alongside' => array( true, 2 ),
+ 'replacing' => array( 'replace', 1 ),
+ );
+ }
+}