add_data()',
+ 'WP_Dependencies::add_data()',
'6.9.0',
__( 'IE conditional comments are ignored by all supported browsers.' )
);
diff --git a/src/wp-includes/class-wp-editor.php b/src/wp-includes/class-wp-editor.php
index d8616b4573e87..5709dd2ef5c0b 100644
--- a/src/wp-includes/class-wp-editor.php
+++ b/src/wp-includes/class-wp-editor.php
@@ -913,7 +913,10 @@ public static function enqueue_default_editor() {
self::enqueue_scripts( true );
- // Also add wp-includes/css/editor.css.
+ /*
+ * Also add wp-includes/css/editor.css. It is prefetched for the block editor by
+ * wp_prefetch_admin_assets(), which needs updating if this changes.
+ */
wp_enqueue_style( 'editor-buttons' );
if ( is_admin() ) {
diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php
index 36a0487bede56..9104a815314ad 100644
--- a/src/wp-includes/class-wp-scripts.php
+++ b/src/wp-includes/class-wp-scripts.php
@@ -415,39 +415,7 @@ public function do_item( $handle, $group = false ) {
return true;
}
- 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 = esc_url_raw( apply_filters( 'script_loader_src', $src, $handle ) );
+ $src = esc_url_raw( $this->get_src( $handle ) );
if ( ! $src ) {
return true;
@@ -506,6 +474,69 @@ 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.
+ * Like {@see WP_Script_Modules::get_src()}, it is neither sanitized nor escaped, so a caller
+ * can pass it through esc_url_raw(), as WP_Scripts::do_item() does, or esc_url(), once.
+ *
+ * @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 ) ? $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..32fc8878b6044 100644
--- a/src/wp-includes/class-wp-styles.php
+++ b/src/wp-includes/class-wp-styles.php
@@ -224,15 +224,11 @@ 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_src = $this->get_rtl_src( $handle );
- $rtl_tag = sprintf(
+ if ( null !== $rtl_src ) {
+ $rtl_href = esc_url( $rtl_src );
+ $rtl_tag = sprintf(
"\n",
$rel,
esc_attr( $handle ),
@@ -264,6 +260,53 @@ 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.
+ *
+ * Like {@see WP_Styles::get_src()}, the URL is neither sanitized nor escaped, so a caller can
+ * pass it through esc_url() to print it, or esc_url_raw() otherwise, once.
+ *
+ * @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_src( 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;
+ }
+
+ /*
+ * The version and the handle's added arguments are those of the left-to-right stylesheet,
+ * appended the same way, while the filter is passed the handle with `-rtl` appended, as it
+ * always has been.
+ */
+ 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->build_src( $obj->src, $obj->ver, $handle, "$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->build_src( $obj->extra['rtl'], $obj->ver, $handle, "$handle-rtl" );
+ }
+
/**
* Adds extra CSS styles to a registered stylesheet.
*
@@ -401,9 +444,66 @@ public function all_deps( $handles, $recursion = false, $group = false ) {
* @param string $src The source of the enqueued style.
* @param string|false|null $ver The version of the enqueued style.
* @param string $handle The style's registered handle.
- * @return string Style's fully-qualified URL.
+ * @return string Style's fully-qualified URL, escaped for use in an HTML attribute.
*/
public function _css_href( $src, $ver, $handle ) {
+ return esc_url( $this->build_src( $src, $ver, (string) $handle ) );
+ }
+
+ /**
+ * Gets the URL a registered style is loaded from.
+ *
+ * This is the URL printed in the stylesheet's `href` attribute, including the version query
+ * argument and any arguments added to the handle, after the {@see 'style_loader_src'} filter.
+ * Unlike {@see WP_Styles::_css_href()}, it is neither sanitized nor escaped, so a caller can
+ * pass it through esc_url() to print it, or esc_url_raw() otherwise, once. This is the same as
+ * {@see WP_Script_Modules::get_src()}.
+ *
+ * @since 7.2.0
+ *
+ * @param string $handle The style's registered handle.
+ * @return string Style URL, or an empty string when the style is not registered, has no
+ * source of its own because it only aliases other styles, or was filtered away.
+ */
+ public function get_src( string $handle ): string {
+ if ( ! isset( $this->registered[ $handle ] ) ) {
+ return '';
+ }
+
+ $obj = $this->registered[ $handle ];
+
+ /*
+ * An alias, with no source of its own, has no URL. A source of `true`, like that of `colors`,
+ * gets past this, since its URL comes from the 'style_loader_src' filter in build_src(), as
+ * it does when WP_Styles::do_item() prints the stylesheet.
+ */
+ if ( ! $obj->src ) {
+ return '';
+ }
+
+ return $this->build_src( $obj->src, $obj->ver, $handle );
+ }
+
+ /**
+ * Builds a style's fully-qualified URL and passes it through the 'style_loader_src' filter.
+ *
+ * The result is not escaped, so callers can escape it for where it is used.
+ *
+ * @since 7.2.0
+ *
+ * @param string|true $src The source of the style, or true for one whose URL comes
+ * from the {@see 'style_loader_src'} filter.
+ * @param string|false|null $ver The version of the style.
+ * @param string $handle The style's registered handle, whose added arguments
+ * are appended.
+ * @param string|null $filter_handle Optional. The handle passed to the
+ * {@see 'style_loader_src'} filter, such as
+ * `{$handle}-rtl` for a right-to-left stylesheet.
+ * Default `$handle`.
+ * @return string The filtered URL, or an empty string when the filter returns anything other
+ * than a string.
+ */
+ private function build_src( $src, $ver, string $handle, ?string $filter_handle = null ): string {
if ( ! is_bool( $src ) && ! preg_match( '|^(https?:)?//|', $src ) && ! ( $this->content_url && str_starts_with( $src, $this->content_url ) ) ) {
$src = $this->base_url . $src;
}
@@ -443,8 +543,9 @@ public function _css_href( $src, $ver, $handle ) {
* @param string $src The source URL of the enqueued style.
* @param string $handle The style's registered handle.
*/
- $src = apply_filters( 'style_loader_src', $src, $handle );
- return esc_url( $src );
+ $src = apply_filters( 'style_loader_src', $src, $filter_handle ?? $handle );
+
+ return is_string( $src ) ? $src : '';
}
/**
diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php
index 7fa12c8802a8f..cad7014e1f51e 100644
--- a/src/wp-includes/default-filters.php
+++ b/src/wp-includes/default-filters.php
@@ -398,6 +398,7 @@
add_action( 'login_head', 'print_admin_styles', 9 );
add_action( 'login_head', 'wp_site_icon', 99 );
add_action( 'login_footer', 'wp_print_footer_scripts', 20 );
+add_action( 'login_footer', 'wp_prefetch_admin_assets', 21 ); // By the footer, the login screen has enqueued everything it loads, so those are skipped.
add_action( 'login_init', 'send_frame_options_header', 10, 0 );
add_action( 'login_init', 'wp_admin_headers' );
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 6b01efa1718ce..c8505d9d0cfc0 100644
--- a/src/wp-includes/script-loader.php
+++ b/src/wp-includes/script-loader.php
@@ -2477,10 +2477,7 @@ function script_concat_settings() {
$can_compress_scripts = ! wp_installing() && get_site_option( 'can_compress_scripts' );
if ( ! isset( $concatenate_scripts ) ) {
- $concatenate_scripts = defined( 'CONCATENATE_SCRIPTS' ) ? CONCATENATE_SCRIPTS : true;
- if ( ( ! is_admin() && ! did_action( 'login_init' ) ) || ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ) {
- $concatenate_scripts = false;
- }
+ $concatenate_scripts = ( is_admin() || did_action( 'login_init' ) ) && wp_should_concatenate_admin_scripts();
}
if ( ! isset( $compress_scripts ) ) {
@@ -2498,6 +2495,589 @@ function script_concat_settings() {
}
}
+/**
+ * Determines whether scripts and styles are concatenated on admin screens and the login screen.
+ *
+ * Concatenation is on unless the `CONCATENATE_SCRIPTS` constant turns it off, and `SCRIPT_DEBUG`
+ * turns it off regardless. Scripts and styles are never concatenated elsewhere.
+ *
+ * This is the default that script_concat_settings() gives the `$concatenate_scripts` global when
+ * the global has not already been set. It is also how wp_prefetch_admin_assets() predicts, from the
+ * login screen, what the admin screen the login leads to will do.
+ *
+ * This function and its filter are intended to be removed before 7.2-beta1. They exist only while
+ * concatenation is still an option, and once concatenation is retired, there is nothing left for
+ * them to decide. Do not rely on them; setting the `$concatenate_scripts` global remains the way
+ * to override concatenation on a request.
+ *
+ * @since 7.2.0
+ *
+ * @return bool Whether scripts and styles are concatenated on admin screens and the login screen.
+ */
+function wp_should_concatenate_admin_scripts(): bool {
+ $concatenate = ( defined( 'CONCATENATE_SCRIPTS' ) ? (bool) CONCATENATE_SCRIPTS : true )
+ && ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG );
+
+ /**
+ * Filters whether scripts and styles are concatenated on admin screens and the login screen.
+ *
+ * Setting the `$concatenate_scripts` global directly still takes precedence over this filter on
+ * the request where it is set.
+ *
+ * An admin screen settles the global the first time scripts are registered, when the
+ * {@see 'wp_default_scripts'} action registers TinyMCE. That can be as early as while plugins
+ * load, before the theme's functions.php, whereas the login screen reads this filter only when
+ * it prints, to predict what the admin will do. So add a callback when a plugin loads, rather
+ * than from a theme or on a later hook, or the admin may not see it while the login screen does.
+ *
+ * @since 7.2.0
+ *
+ * @param bool $concatenate Whether scripts and styles are concatenated. Default true, unless the
+ * `CONCATENATE_SCRIPTS` constant is false or `SCRIPT_DEBUG` is true.
+ */
+ return (bool) apply_filters( 'wp_should_concatenate_admin_scripts', $concatenate );
+}
+
+/**
+ * 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 printed or queued are skipped, along with their dependencies, so
+ * each context only fetches what it is actually adding. On the login screen this runs in the
+ * footer, since some of what the login form loads, such as `user-profile` and the `jquery` it
+ * depends on, is only enqueued after its header has printed. On admin screens it runs in the head,
+ * where the screen's assets have been enqueued already.
+ *
+ * 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. They are printed on every screen of the login
+ * page, such as the password reset form, since those mostly lead to the admin as well. Nothing is
+ * printed at all when `redirect_to` points outside this site's admin.
+ *
+ * Nothing is printed when the next screen will concatenate its assets, 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()
+ * @see wp_should_concatenate_admin_scripts()
+ *
+ * @global bool $concatenate_scripts Whether scripts and styles are concatenated.
+ * @global WP_Screen|null $current_screen The current admin screen, if any.
+ */
+function wp_prefetch_admin_assets(): void {
+ global $concatenate_scripts, $current_screen;
+
+ /*
+ * The context is told apart by the request rather than by the hook this runs on, so it works
+ * from whichever hook it is added to. The 'login_init' action is fired by wp-login.php however
+ * it is reached, which is also how script_concat_settings() recognizes it. The is_login()
+ * function would not do: it compares the login URL with the script handling the request, and a
+ * plugin serving the login screen at a URL of its own runs wp-login.php from another script,
+ * such as index.php.
+ */
+ $on_login = (bool) did_action( 'login_init' );
+
+ if ( $on_login ) {
+ /*
+ * 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 what wp_should_concatenate_admin_scripts() predicts.
+ */
+ if ( wp_should_concatenate_admin_scripts() ) {
+ 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.
+ *
+ * The global is read rather than calling get_current_screen(), which only exists once the
+ * admin includes are loaded. On a request with no screen, such as one for the front end,
+ * this prints nothing.
+ */
+ if ( ! $current_screen instanceof WP_Screen || ! in_array( $current_screen->base, array( 'dashboard', 'edit' ), true ) ) {
+ return;
+ }
+
+ /*
+ * On an admin screen the global has settled by the time its head is printed, and the editor
+ * will be served the same way.
+ */
+ script_concat_settings();
+
+ if ( $concatenate_scripts ) {
+ return;
+ }
+ }
+
+ $script_roots = array();
+ $style_roots = array();
+
+ if ( $on_login ) {
+ /*
+ * 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_safe_redirect() sends the browser to what
+ * wp_validate_redirect() returns, so that is used here as it is: it sanitizes the value,
+ * resolves a relative path against the current request, as the browser would, and falls back
+ * to the admin for a value pointing off-host. Passing the value through esc_url_raw() first
+ * would not match it, since that takes a relative path such as `wp-admin/post.php` for a host.
+ *
+ * This runs on every screen wp-login.php prints, not only the login form, since the others
+ * mostly lead to the admin as well, and each one gives the prefetching another chance to
+ * finish before the user gets there. The password reset and registration flows end at the
+ * login form, and the admin email confirmation follows a login that has already succeeded.
+ * On the lost password and registration screens, `redirect_to` is where submitting the
+ * form goes rather than where the login lands, but it is normally absent, and when it is
+ * not, it typically points back into wp-login.php, so nothing is prefetched. Nor does an
+ * interim login need to be excluded: it shows inside a modal on an admin screen that has
+ * already loaded these assets, so they are served from the HTTP cache.
+ *
+ * An empty `redirect_to` counts as none, since wp-login.php falls back to the admin for it
+ * too. The lost password form submits one, for instance, so it is present when the form is
+ * shown again with an error.
+ */
+ $admin_url = admin_url();
+ $next_screen = $admin_url;
+
+ if ( ! empty( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) {
+ $next_screen = wp_validate_redirect( 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. The admin's path ends in a slash, which a
+ * `redirect_to` of the admin itself may leave off, as in `/wp-admin`.
+ */
+ $admin_path = (string) wp_parse_url( $admin_url, PHP_URL_PATH );
+
+ if ( '' === $admin_path || ! str_starts_with( trailingslashit( (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. The same goes
+ * for this host's admin under another scheme, such as an `https` destination from an `http`
+ * login: the URLs prefetched here take the scheme of the current request, so that admin
+ * would request different ones. A relative `redirect_to` stays on this host and scheme, while
+ * wp_validate_redirect() gives a protocol-relative one the `http` scheme, which is where
+ * wp_safe_redirect() then sends it. A port is compared with the scheme's default filled in
+ * when none is given, since `https://example.com:443/` is the same admin as
+ * `https://example.com/`.
+ */
+ $next_screen_host = wp_parse_url( $next_screen, PHP_URL_HOST );
+
+ if ( is_string( $next_screen_host ) ) {
+ $default_ports = array(
+ 'http' => 80,
+ 'https' => 443,
+ );
+
+ $admin_scheme = strtolower( (string) wp_parse_url( $admin_url, PHP_URL_SCHEME ) );
+ $next_screen_scheme = strtolower( (string) wp_parse_url( $next_screen, PHP_URL_SCHEME ) );
+ $admin_port = wp_parse_url( $admin_url, PHP_URL_PORT ) ?? $default_ports[ $admin_scheme ] ?? null;
+ $next_screen_port = wp_parse_url( $next_screen, PHP_URL_PORT ) ?? $default_ports[ $next_screen_scheme ] ?? null;
+
+ if (
+ strtolower( $next_screen_host ) !== strtolower( (string) wp_parse_url( $admin_url, PHP_URL_HOST ) ) ||
+ $next_screen_scheme !== $admin_scheme ||
+ $next_screen_port !== $admin_port
+ ) {
+ return;
+ }
+ }
+ } else {
+ $post_type = ( 'edit' === $current_screen->base && $current_screen->post_type ) ? $current_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 edit posts of this type will never reach the editor from here. This is
+ * `edit_posts` rather than `create_posts`, since the editor is reached by opening an existing
+ * post as well as by adding a new one, and a user may be able to do the first but not the
+ * second. Adding a new one requires `edit_posts` too.
+ */
+ if ( ! current_user_can( $post_type_object->cap->edit_posts ) ) {
+ 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 ) {
+ /*
+ * The editor only loads these stylesheets when the post type uses the block editor, which
+ * the classic editor, for instance, can turn off. The post type is the one in the query of
+ * the next screen, as for post-new.php. An edit link to post.php from the login screen does
+ * not carry one, so it is taken to be a post: looking the post up instead would let anyone
+ * tell from the login screen whether a post with a given ID exists, drafts and private posts
+ * included.
+ */
+ $next_screen_post_type = $next_screen_query['post_type'] ?? 'post';
+ $next_screen_is_block_editor = is_string( $next_screen_post_type ) && use_block_editor_for_post_type( $next_screen_post_type );
+ }
+
+ 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',
+ )
+ );
+ }
+
+ // From an admin screen, nothing is left to prefetch when the editor turns out not to be the block editor.
+ if ( ! $script_roots && ! $style_roots ) {
+ return;
+ }
+
+ $resources = array();
+
+ foreach (
+ array(
+ 'script' => array( wp_scripts(), $script_roots ),
+ 'style' => array( wp_styles(), $style_roots ),
+ )
+ as $as => list( $dependencies, $roots )
+ ) {
+ // Skip a type with nothing to prefetch, such as scripts for the editor, rather than expanding the current screen's queue of it.
+ if ( ! $roots ) {
+ continue;
+ }
+
+ /*
+ * Expand the roots to include everything they depend on, roots first, and likewise what the
+ * current screen has queued. A handle that is not registered is dropped along with its
+ * dependencies. Unlike WP_Dependencies::all_deps(), this leaves the dependencies' state
+ * untouched.
+ */
+ $expanded = array(
+ 'roots' => array(),
+ 'queue' => array(),
+ );
+
+ foreach (
+ array(
+ 'roots' => $roots,
+ 'queue' => $dependencies->queue,
+ )
+ as $list => $handles
+ ) {
+ while ( $handles ) {
+ $handle = array_shift( $handles );
+
+ if ( isset( $expanded[ $list ][ $handle ] ) || ! isset( $dependencies->registered[ $handle ] ) ) {
+ continue;
+ }
+
+ $expanded[ $list ][ $handle ] = true;
+
+ foreach ( $dependencies->registered[ $handle ]->deps as $dependency ) {
+ if ( is_string( $dependency ) && '' !== $dependency && ! isset( $expanded[ $list ][ $dependency ] ) ) {
+ $handles[] = $dependency;
+ }
+ }
+ }
+ }
+
+ /*
+ * Whichever screen this is running on shares some of these handles, and the browser fetches
+ * those for it anyway, so prefetching them as well would only compete with its own requests.
+ * These are the ones it has printed, and the ones it has queued along with their
+ * dependencies, which it has yet to print if this runs before its footer scripts. On the
+ * login screen, for instance, `user-profile` brings in `jquery`.
+ */
+ $on_current_screen = $expanded['queue'] + array_fill_keys( $dependencies->done, true );
+
+ foreach ( array_keys( $expanded['roots'] ) as $handle ) {
+ if ( isset( $on_current_screen[ $handle ] ) ) {
+ continue;
+ }
+
+ // The next screen prints nothing for a handle with conditional data, as do_item() returns early for it.
+ if ( $dependencies->registered[ $handle ]->extra['conditional'] ?? false ) {
+ continue;
+ }
+
+ /*
+ * The URLs are built the same way WP_Scripts::do_item() and WP_Styles::do_item() build
+ * those in the tags they print, so they match what the next screen will request. They are
+ * not escaped yet, so the filter sees plain URLs; they are escaped when printed.
+ *
+ * A handle with no URL of its own, such as an alias or one whose URL was filtered away,
+ * prints nothing either, and for a style that includes its right-to-left stylesheet,
+ * since WP_Styles::do_item() returns before getting to it.
+ */
+ $src = $dependencies->get_src( $handle );
+
+ if ( '' === $src ) {
+ continue;
+ }
+
+ $urls = array( $src );
+
+ if ( $dependencies instanceof WP_Styles ) {
+ $rtl_src = $dependencies->get_rtl_src( $handle );
+
+ if ( null !== $rtl_src ) {
+ if ( 'replace' === $dependencies->get_data( $handle, 'rtl' ) ) {
+ $urls = array( $rtl_src );
+ } else {
+ $urls[] = $rtl_src;
+ }
+ }
+ }
+
+ 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( 'wp_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;
+ }
+
+ /*
+ * Check again once escaped, since esc_url() returns an empty string for a URL it rejects.
+ * An empty `href` would resolve to the current page, which the script below would then fetch.
+ * Only `http` and `https` URLs are allowed, as wp_preload_resources() does, since nothing else
+ * can be prefetched, and the script would pass anything else to fetch().
+ */
+ $href = esc_url( $href, array( 'http', 'https' ) );
+
+ if ( '' === $href || isset( $unique_resources[ $href ] ) ) {
+ continue;
+ }
+
+ $unique_resources[ $href ] = $as;
+ }
+
+ if ( ! $unique_resources ) {
+ return;
+ }
+
+ // Build and output the HTML for each unique resource. Each URL has already been escaped.
+ foreach ( $unique_resources as $escaped_href => $as ) {
+ printf(
+ "\n",
+ $escaped_href,
+ esc_attr( $as )
+ );
+ }
+
+ /*
+ * Safari does not support `rel="prefetch"`, so in browsers that lack it, fetch the URLs of the
+ * page's prefetch links with fetch() instead, to put the responses in the HTTP cache all the
+ * same. Reading the links from the page, rather than passing their URLs to the script, keeps a
+ * single list of them, and also covers any prefetch links added by plugins. The fetches wait
+ * until the current screen has loaded, and ask for low priority, so they stay out of the way
+ * of its own assets. Unlike a prefetch link, a fetch still in flight is canceled when the user
+ * navigates away, so it cannot compete with the next screen's render-blocking assets.
+ *
+ * The requests use `no-cors` mode and include credentials, as the stylesheet and script
+ * requests of the next screen do, so the cached responses match what that screen will ask for.
+ * The body is read to completion so that the full response is cached.
+ */
+ $js_function = <<<'JS'
+ /**
+ * Prefetches the page's rel=prefetch links with fetch() in browsers that do not support rel=prefetch.
+ *
+ * @see https://caniuse.com/link-rel-prefetch
+ */
+ () => {
+ if ( document.createElement( 'link' ).relList?.supports?.( 'prefetch' ) ) {
+ return;
+ }
+
+ const prefetch = () => {
+ for ( const link of /** @type {NodeListOf} */ ( document.querySelectorAll( 'link[rel~="prefetch"][href]' ) ) ) {
+ fetch( link.href, { mode: 'no-cors', credentials: 'include', priority: 'low' } )
+ .then( ( response ) => response.blob() )
+ .catch( () => {} );
+ }
+ };
+
+ const schedule = () => {
+ if ( 'requestIdleCallback' in window ) {
+ window.requestIdleCallback( prefetch );
+ } else {
+ setTimeout( prefetch, 0 );
+ }
+ };
+
+ if ( 'complete' === document.readyState ) {
+ schedule();
+ } else {
+ window.addEventListener( 'load', schedule, { once: true } );
+ }
+ }
+ JS;
+
+ wp_print_inline_script_tag( "( $js_function )();\n//# sourceURL=" . rawurlencode( __FUNCTION__ ) );
+}
+
/**
* Handles the enqueueing of block scripts and styles that are common to both
* the editor and the front-end.
@@ -2916,6 +3496,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 +3507,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 +4189,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/scripts.php b/tests/phpunit/tests/dependencies/scripts.php
index adb8f2d3d04a0..000838f0fbd0c 100644
--- a/tests/phpunit/tests/dependencies/scripts.php
+++ b/tests/phpunit/tests/dependencies/scripts.php
@@ -2350,7 +2350,7 @@ public function test_wp_script_add_data_with_data_key() {
/**
* Testing `wp_script_add_data` with the conditional key.
*
- * @expectedDeprecated WP_Dependencies->add_data()
+ * @expectedDeprecated WP_Dependencies::add_data()
*
* @since 6.9.0 Conditional comments should now return an empty string.
*
@@ -2770,7 +2770,7 @@ public function test_wp_add_inline_script_after_with_concat() {
}
/**
- * @expectedDeprecated WP_Dependencies->add_data()
+ * @expectedDeprecated WP_Dependencies::add_data()
*
* @ticket 14853
* @ticket 63821
@@ -2821,7 +2821,7 @@ public function test_wp_add_inline_script_after_with_concat_and_core_dependency(
}
/**
- * @expectedDeprecated WP_Dependencies->add_data()
+ * @expectedDeprecated WP_Dependencies::add_data()
*
* @ticket 36392
* @ticket 63821
diff --git a/tests/phpunit/tests/dependencies/styles.php b/tests/phpunit/tests/dependencies/styles.php
index 626a5861eb782..6f394d8c78697 100644
--- a/tests/phpunit/tests/dependencies/styles.php
+++ b/tests/phpunit/tests/dependencies/styles.php
@@ -364,7 +364,7 @@ public function test_unnecessary_style_tags() {
* Test to make sure that inline styles attached to conditional
* stylesheets are also conditional.
*
- * @expectedDeprecated WP_Dependencies->add_data()
+ * @expectedDeprecated WP_Dependencies::add_data()
*/
public function test_conditional_inline_styles_are_also_conditional() {
wp_enqueue_style( 'handle', 'http://example.com', array(), 1 );
diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php
new file mode 100644
index 0000000000000..3176ff87e3e16
--- /dev/null
+++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php
@@ -0,0 +1,1153 @@
+
+ */
+ private $original_globals = array();
+
+ /**
+ * Name of the script handling the request before the test, which tests on the login screen change.
+ *
+ * @var mixed
+ */
+ private $original_script_name;
+
+ /**
+ * Gives each test fresh script and style registries, since several tests modify them, and turns
+ * concatenation off.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ foreach ( array( 'wp_scripts', 'wp_styles', 'concatenate_scripts' ) as $name ) {
+ $this->original_globals[ $name ] = $GLOBALS[ $name ] ?? null;
+ unset( $GLOBALS[ $name ] );
+ }
+
+ $this->original_script_name = $_SERVER['SCRIPT_NAME'] ?? null;
+
+ add_filter( 'wp_should_concatenate_admin_scripts', '__return_false' );
+ }
+
+ /**
+ * Restores the globals the test replaced.
+ */
+ public function tear_down(): void {
+ foreach ( $this->original_globals as $name => $value ) {
+ if ( null === $value ) {
+ unset( $GLOBALS[ $name ] );
+ } else {
+ $GLOBALS[ $name ] = $value;
+ }
+ }
+
+ if ( null === $this->original_script_name ) {
+ unset( $_SERVER['SCRIPT_NAME'] );
+ } else {
+ $_SERVER['SCRIPT_NAME'] = $this->original_script_name;
+ }
+
+ parent::tear_down();
+ }
+
+ /**
+ * Tests that nothing is prefetched from the login screen when the admin concatenates its assets.
+ *
+ * @ticket 57548
+ */
+ public function test_login_prints_nothing_when_admin_concatenates(): void {
+ add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' );
+
+ $this->assertSame( array(), $this->get_prefetched_on_login() );
+ }
+
+ /**
+ * Tests that the login screen predicts what the admin will do rather than reading the
+ * `$concatenate_scripts` global, which script_concat_settings() may have settled on false before
+ * 'login_init' fired.
+ *
+ * @ticket 57548
+ */
+ public function test_login_ignores_concatenate_scripts_global(): void {
+ $GLOBALS['concatenate_scripts'] = true;
+
+ $this->assertNotSame( array(), $this->get_prefetched_on_login(), 'Expected prefetching when the admin will not concatenate.' );
+
+ $GLOBALS['concatenate_scripts'] = false;
+ add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' );
+
+ $this->assertSame( array(), $this->get_prefetched_on_login(), 'Expected no prefetching when the admin will concatenate.' );
+ }
+
+ /**
+ * Tests that an admin screen goes by the `$concatenate_scripts` global, which has settled by the
+ * time its head is printed, so a plugin setting the global is respected.
+ *
+ * @ticket 57548
+ */
+ public function test_admin_screen_uses_concatenate_scripts_global(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+
+ $GLOBALS['concatenate_scripts'] = true;
+
+ $this->assertSame( array(), $this->get_prefetched_on_admin_screen( 'dashboard' ), 'Expected no prefetching when the global says to concatenate.' );
+
+ $GLOBALS['concatenate_scripts'] = false;
+ add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' );
+
+ $this->assertNotSame( array(), $this->get_prefetched_on_admin_screen( 'dashboard' ), 'Expected prefetching when the global says not to concatenate.' );
+ }
+
+ /**
+ * Tests that an admin screen settles the `$concatenate_scripts` global with script_concat_settings()
+ * when nothing has done so yet.
+ *
+ * @ticket 57548
+ */
+ public function test_admin_screen_settles_concatenate_scripts_global(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' );
+
+ $this->assertSame( array(), $this->get_prefetched_on_admin_screen( 'dashboard' ) );
+ $this->assertTrue( $GLOBALS['concatenate_scripts'] );
+ }
+
+ /**
+ * 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
+ */
+ public function test_login_prefetches_render_blocking_admin_assets(): void {
+ $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
+ *
+ * @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 {
+ $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 ),
+ 'Dashboard, no trailing slash' => 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 ),
+ 'relative, editing a post' => array( 'wp-admin/post.php?post=1&action=edit', true ),
+ 'relative, new post' => array( 'wp-admin/post-new.php', true ),
+ );
+ }
+
+ /**
+ * Tests that an absolute `redirect_to` pointing at this site's editor also prefetches the
+ * editor's stylesheets.
+ *
+ * @ticket 57548
+ */
+ public function test_login_prefetches_editor_assets_for_absolute_editor_url(): void {
+ $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 a login leading to the editor of a post type that does not use the block editor
+ * prefetches only the admin's stylesheets, not the block editor's.
+ *
+ * The classic editor is turned off for pages only, so the post type is what decides. An edit
+ * link to post.php does not name its post type, so it is taken to be a post.
+ *
+ * @ticket 57548
+ *
+ * @dataProvider data_editor_destinations_by_post_type
+ *
+ * @param non-falsy-string $redirect_to Where the login redirects to.
+ * @param bool $is_editor Whether that is expected to be the block editor.
+ */
+ public function test_login_checks_block_editor_for_post_type( string $redirect_to, bool $is_editor ): void {
+ add_filter(
+ 'use_block_editor_for_post_type',
+ static function ( bool $use_block_editor, string $post_type ): bool {
+ return 'page' === $post_type ? false : $use_block_editor;
+ },
+ 10,
+ 2
+ );
+
+ $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#' );
+ } else {
+ $this->assertNotPrefetched( $links, '#/wp-includes/css/dist/edit-post/#' );
+ }
+ }
+
+ /**
+ * Data provider for {@see self::test_login_checks_block_editor_for_post_type()}.
+ *
+ * @return array
+ */
+ public function data_editor_destinations_by_post_type(): array {
+ return array(
+ 'new post' => array( '/wp-admin/post-new.php', true ),
+ 'new page' => array( '/wp-admin/post-new.php?post_type=page', false ),
+ 'unregistered post type' => array( '/wp-admin/post-new.php?post_type=nonexistent', false ),
+ 'post type not a string' => array( '/wp-admin/post-new.php?post_type[]=post', false ),
+ 'editing a post' => array( '/wp-admin/post.php?post=1&action=edit', true ),
+ );
+ }
+
+ /**
+ * Tests that a login leading to the editor prefetches only the admin's stylesheets when the
+ * classic editor replaces the block editor for every post type.
+ *
+ * @ticket 57548
+ */
+ public function test_login_prints_no_editor_assets_for_classic_editor(): void {
+ add_filter( 'use_block_editor_for_post_type', '__return_false' );
+
+ foreach ( array( '/wp-admin/post-new.php', '/wp-admin/post.php?post=1&action=edit' ) as $redirect_to ) {
+ $links = $this->get_prefetched_on_login( array( 'redirect_to' => $redirect_to ) );
+
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ $this->assertNotPrefetched( $links, '#/wp-includes/css/dist/edit-post/#' );
+ }
+ }
+
+ /**
+ * Tests that every screen of the login page prefetches the admin's assets, not only the login
+ * form, since the others mostly lead to the admin as well.
+ *
+ * @ticket 57548
+ *
+ * @dataProvider data_login_screens
+ *
+ * @param array $request Request parameters of the login screen.
+ */
+ public function test_login_prefetches_on_every_login_screen( array $request ): void {
+ $links = $this->get_prefetched_on_login( $request );
+
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ }
+
+ /**
+ * Data provider for {@see self::test_login_prefetches_on_every_login_screen()}.
+ *
+ * @return array }>
+ */
+ public function data_login_screens(): array {
+ return array(
+ 'login form' => array( array() ),
+ 'lost password' => array( array( 'action' => 'lostpassword' ) ),
+ 'registration' => array( array( 'action' => 'register' ) ),
+ 'password reset key' => array(
+ array(
+ 'key' => 'abc',
+ 'login' => 'admin',
+ ),
+ ),
+ 'check email' => array( array( 'checkemail' => 'confirm' ) ),
+ 'interim login' => array( array( 'interim-login' => '1' ) ),
+ 'unrecognized action' => array( array( 'action' => 'unrecognized' ) ),
+ 'empty redirect' => array( array( 'redirect_to' => '' ) ),
+ 'lost password error' => array(
+ array(
+ 'action' => 'lostpassword',
+ 'redirect_to' => '',
+ ),
+ ),
+ 'admin email reminder' => array(
+ array(
+ 'action' => 'confirm_admin_email',
+ 'redirect_to' => '/wp-admin/',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Tests that nothing is prefetched from login screens that do not lead to the admin.
+ *
+ * @ticket 57548
+ *
+ * @dataProvider data_login_requests_not_leading_to_admin
+ *
+ * @param array $request Request parameters of the login screen.
+ */
+ public function test_login_prints_nothing_when_not_leading_to_admin( array $request ): void {
+ $this->assertSame( array(), $this->get_prefetched_on_login( $request ) );
+ }
+
+ /**
+ * Data provider for {@see self::test_login_prints_nothing_when_not_leading_to_admin()}.
+ *
+ * On the lost password and registration screens, `redirect_to` is where submitting the form
+ * goes, which is typically back into wp-login.php.
+ *
+ * @return array }>
+ */
+ public function data_login_requests_not_leading_to_admin(): array {
+ return array(
+ 'front end redirect' => array( array( 'redirect_to' => '/hello-world/' ) ),
+ 'lookalike admin path' => array( array( 'redirect_to' => '/wp-admin-lookalike/' ) ),
+ // A relative path, which the browser would resolve to /example.org/wp-admin/, not to this site's admin.
+ 'relative path resembling a host' => array( array( 'redirect_to' => 'example.org/wp-admin/' ) ),
+ 'lost password redirecting to login' => array(
+ array(
+ 'action' => 'lostpassword',
+ 'redirect_to' => 'wp-login.php?checkemail=confirm',
+ ),
+ ),
+ 'registration redirecting to login' => array(
+ array(
+ 'action' => 'register',
+ 'redirect_to' => 'wp-login.php?checkemail=registered',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Tests that nothing is prefetched when an absolute `redirect_to` points at this site's front end.
+ *
+ * @ticket 57548
+ */
+ public function test_login_prints_nothing_for_absolute_front_end_redirect(): void {
+ $this->assertSame( array(), $this->get_prefetched_on_login( array( 'redirect_to' => home_url( '/hello-world/' ) ) ) );
+ }
+
+ /**
+ * Tests that a `redirect_to` pointing off-site falls back to the admin, as wp_safe_redirect() does.
+ *
+ * @ticket 57548
+ */
+ public function test_login_off_site_redirect_falls_back_to_admin(): void {
+ $filter = new MockAction();
+ add_filter( 'wp_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
+ */
+ public function test_login_prints_nothing_for_admin_on_another_allowed_host(): void {
+ 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
+ */
+ public function test_login_prints_nothing_for_admin_on_another_port(): void {
+ $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 nothing is prefetched when the login lands in this site's admin under another scheme,
+ * since the URLs prefetched take the scheme of the login screen and that admin would request
+ * different ones.
+ *
+ * @ticket 57548
+ */
+ public function test_login_prints_nothing_for_admin_on_another_scheme(): void {
+ $scheme = (string) wp_parse_url( admin_url(), PHP_URL_SCHEME );
+ $other_scheme = 'https' === $scheme ? 'http' : 'https';
+
+ $this->assertSame(
+ array(),
+ $this->get_prefetched_on_login( array( 'redirect_to' => set_url_scheme( admin_url( 'post-new.php' ), $other_scheme ) ) )
+ );
+ }
+
+ /**
+ * Tests that an explicit port that is the scheme's default counts as the admin's own, since
+ * `http://example.org:80/` is the same admin as `http://example.org/`.
+ *
+ * @ticket 57548
+ */
+ public function test_login_prefetches_for_admin_with_explicit_default_port(): void {
+ $scheme = (string) wp_parse_url( admin_url(), PHP_URL_SCHEME );
+ $host = (string) wp_parse_url( admin_url(), PHP_URL_HOST );
+ $path = (string) wp_parse_url( admin_url( 'post-new.php' ), PHP_URL_PATH );
+ $this->assertNull( wp_parse_url( admin_url(), PHP_URL_PORT ), 'Expected the admin URL to have no port.' );
+
+ $default_port = 'https' === $scheme ? 443 : 80;
+
+ $links = $this->get_prefetched_on_login( array( 'redirect_to' => "{$scheme}://{$host}:{$default_port}{$path}" ) );
+
+ $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 a protocol-relative `redirect_to` pointing at this site's admin prefetches when the
+ * admin is served over `http`, since wp_validate_redirect() gives such a value the `http` scheme.
+ *
+ * @ticket 57548
+ */
+ public function test_login_prefetches_for_protocol_relative_admin_url(): void {
+ $this->assertSame( 'http', wp_parse_url( admin_url(), PHP_URL_SCHEME ), 'Expected the admin to be served over http.' );
+
+ $redirect_to = (string) preg_replace( '#^https?:#', '', admin_url( 'post-new.php' ) );
+ $this->assertStringStartsWith( '//', $redirect_to );
+
+ $links = $this->get_prefetched_on_login( array( 'redirect_to' => $redirect_to ) );
+
+ $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 an `https` login screen for a protocol-relative
+ * `redirect_to`, since wp_validate_redirect() gives it the `http` scheme, which is where
+ * wp_safe_redirect() then sends the browser.
+ *
+ * @ticket 57548
+ */
+ public function test_login_prints_nothing_for_protocol_relative_admin_url_over_https(): void {
+ $_SERVER['HTTPS'] = 'on';
+ $this->assertSame( 'https', wp_parse_url( admin_url(), PHP_URL_SCHEME ), 'Expected the admin to be served over https.' );
+
+ $redirect_to = (string) preg_replace( '#^https?:#', '', admin_url( 'post-new.php' ) );
+
+ $this->assertSame( array(), $this->get_prefetched_on_login( array( 'redirect_to' => $redirect_to ) ) );
+ }
+
+ /**
+ * Tests that the Dashboard and the post list tables prefetch only the editor's stylesheets.
+ *
+ * @ticket 57548
+ *
+ * @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 {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+
+ $filter = new MockAction();
+ add_filter( 'wp_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
+ *
+ * @dataProvider data_other_admin_screens
+ *
+ * @param string $screen Screen ID.
+ */
+ public function test_other_admin_screen_prints_nothing( string $screen ): void {
+ 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 edit posts of the type listed.
+ *
+ * @ticket 57548
+ */
+ public function test_admin_screen_prints_nothing_for_user_who_cannot_edit_posts(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) );
+
+ $this->assertSame( array(), $this->get_prefetched_on_admin_screen( 'dashboard' ) );
+ }
+
+ /**
+ * Tests that the editor's stylesheets are prefetched for a user who can edit posts of the type
+ * listed but not create them, since opening an existing post leads to the editor as well.
+ *
+ * A Contributor can edit patterns but not create them, which requires `publish_posts`.
+ *
+ * @ticket 57548
+ */
+ public function test_admin_screen_prefetches_for_user_who_can_edit_but_not_create_posts(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'contributor' ) ) );
+
+ $post_type_object = get_post_type_object( 'wp_block' );
+ $this->assertInstanceOf( WP_Post_Type::class, $post_type_object );
+ $this->assertTrue( current_user_can( $post_type_object->cap->edit_posts ), 'Expected the user to be able to edit patterns.' );
+ $this->assertFalse( current_user_can( $post_type_object->cap->create_posts ), 'Expected the user not to be able to create patterns.' );
+
+ $links = $this->get_prefetched_on_admin_screen( 'edit-wp_block' );
+
+ $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/edit-post/style(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that nothing is prefetched for a post type that uses the classic editor, and that the
+ * filter is not applied either, since there is no destination to prefetch for.
+ *
+ * @ticket 57548
+ */
+ public function test_admin_screen_prints_nothing_for_classic_editor(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ add_filter( 'use_block_editor_for_post_type', '__return_false' );
+
+ $filter = new MockAction();
+ add_filter( 'wp_prefetch_admin_assets', array( $filter, 'filter' ) );
+
+ $this->assertSame( array(), $this->get_prefetched_on_admin_screen( 'edit' ) );
+ $this->assertSame( 0, $filter->get_call_count(), 'Expected the filter not to be applied.' );
+ }
+
+ /**
+ * Tests that assets the current screen has already printed are not prefetched again.
+ *
+ * @ticket 57548
+ */
+ public function test_skips_assets_already_printed(): void {
+ 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 assets the current screen has queued but not yet printed are not prefetched either,
+ * along with their dependencies, so the result does not depend on whether this runs before or
+ * after the screen's footer scripts.
+ *
+ * @ticket 57548
+ */
+ public function test_skips_assets_queued_but_not_yet_printed(): void {
+ wp_enqueue_script( 'user-profile' );
+ wp_enqueue_style( 'forms' );
+
+ $links = $this->get_prefetched_on_login();
+
+ $this->assertSame( array(), wp_scripts()->done, 'Expected no scripts to have been printed.' );
+ $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery(\.min)?\.js#' );
+ $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery-migrate(\.min)?\.js#' );
+ $this->assertNotPrefetched( $links, '#/wp-admin/css/forms(\.min)?\.css#' );
+ $this->assertPrefetched( $links, 'script', '#/wp-includes/js/utils(\.min)?\.js#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that the login screen skips scripts it loads itself in the footer.
+ *
+ * wp-login.php enqueues `user-profile` after its header has printed, and that script brings in
+ * `jquery`, so the login screen loads jQuery in its footer. With the callbacks registered in
+ * default-filters.php, the prefetching runs in the footer, after the footer scripts, so it
+ * leaves jQuery out but still prefetches `utils`, which the login screen does not load.
+ *
+ * @ticket 57548
+ */
+ public function test_login_skips_scripts_printed_in_footer(): void {
+ $this->assertFalse( has_action( 'login_head', 'wp_prefetch_admin_assets' ), 'Expected no prefetching from the head of the login screen.' );
+ $this->assertIsInt( has_action( 'login_footer', 'wp_prefetch_admin_assets' ), 'Expected prefetching from the footer of the login screen.' );
+
+ $this->go_to_login_screen();
+ wp_enqueue_script( 'user-profile' );
+
+ $output = get_echo( 'do_action', array( 'login_footer' ) );
+ $links = $this->parse_prefetch_links( $output );
+
+ // Find where jQuery's script tag and the first prefetch link are among the tags printed.
+ $processor = new WP_HTML_Tag_Processor( $output );
+ $tag_index = 0;
+ $jquery_index = null;
+ $first_prefetch_index = null;
+ while ( $processor->next_tag() ) {
+ ++$tag_index;
+
+ if ( 'SCRIPT' === $processor->get_tag() ) {
+ $src = $processor->get_attribute( 'src' );
+ if ( is_string( $src ) && in_array( basename( (string) wp_parse_url( $src, PHP_URL_PATH ) ), array( 'jquery.js', 'jquery.min.js' ), true ) ) {
+ $jquery_index = $jquery_index ?? $tag_index;
+ }
+ } elseif ( 'LINK' === $processor->get_tag() && 'prefetch' === $processor->get_attribute( 'rel' ) ) {
+ $first_prefetch_index = $first_prefetch_index ?? $tag_index;
+ }
+ }
+
+ $this->assertIsInt( $jquery_index, 'Expected the login screen to load jQuery itself with a script tag.' );
+ $this->assertIsInt( $first_prefetch_index, 'Expected prefetch links to be printed.' );
+ $this->assertGreaterThan( $jquery_index, $first_prefetch_index, 'Expected the prefetch links to follow the footer scripts.' );
+
+ $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery(\.min)?\.js#' );
+ $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery-migrate(\.min)?\.js#' );
+ $this->assertPrefetched( $links, 'script', '#/wp-includes/js/utils(\.min)?\.js#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that the login screen is recognized by the request rather than by the hook, so that the
+ * prefetching still works when it is moved to another hook, such as back to the head.
+ *
+ * @ticket 57548
+ */
+ public function test_login_prefetches_from_another_hook(): void {
+ $this->go_to_login_screen();
+
+ remove_all_actions( 'login_head' );
+ add_action( 'login_head', 'wp_prefetch_admin_assets' );
+
+ $links = $this->parse_prefetch_links( get_echo( 'do_action', array( 'login_head' ) ) );
+
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that the login screen is recognized when a plugin serves it at a URL of its own, running
+ * wp-login.php from another script, in which case is_login() does not recognize it.
+ *
+ * @ticket 57548
+ */
+ public function test_login_prefetches_at_custom_login_url(): void {
+ add_filter(
+ 'login_url',
+ static function (): string {
+ return home_url( '/my-login/' );
+ }
+ );
+ $_SERVER['SCRIPT_NAME'] = '/index.php';
+ $this->assertFalse( is_login(), 'Expected is_login() not to recognize the login screen.' );
+
+ $links = $this->get_prefetched_on_login();
+
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that nothing is printed, and no error raised, on a request that is neither for the login
+ * screen nor for an admin screen, such as one for the front end, where get_current_screen() is
+ * not defined unless the admin includes have been loaded.
+ *
+ * @ticket 57548
+ */
+ public function test_prints_nothing_on_front_end(): void {
+ $this->assertSame( 0, did_action( 'login_init' ), 'Expected the request not to be for the login screen.' );
+ $this->assertNull( $GLOBALS['current_screen'] ?? null, 'Expected no current screen.' );
+
+ $this->assertSame( '', get_echo( 'wp_prefetch_admin_assets' ) );
+ }
+
+ /**
+ * Tests that a right-to-left locale prefetches the right-to-left stylesheets.
+ *
+ * @ticket 57548
+ */
+ public function test_prefetches_rtl_stylesheets(): void {
+ 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 a handle with conditional data is not prefetched, since do_item() prints nothing
+ * for it.
+ *
+ * @ticket 57548
+ *
+ * @expectedDeprecated WP_Dependencies::add_data()
+ */
+ public function test_skips_conditional_handles(): void {
+ wp_styles()->add_data( 'forms', 'conditional', 'IE' );
+
+ $links = $this->get_prefetched_on_login();
+
+ $this->assertNotPrefetched( $links, '#/wp-admin/css/forms(\.min)?\.css#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that a style whose own URL is filtered away is not prefetched in a right-to-left locale
+ * either, since WP_Styles::do_item() returns before printing its right-to-left stylesheet.
+ *
+ * @ticket 57548
+ */
+ public function test_skips_rtl_stylesheet_of_style_filtered_away(): void {
+ wp_styles()->text_direction = 'rtl';
+
+ add_filter(
+ 'style_loader_src',
+ static function ( $src, string $handle ) {
+ return 'common' === $handle ? '' : $src;
+ },
+ 10,
+ 2
+ );
+
+ $links = $this->get_prefetched_on_login();
+
+ $this->assertNotPrefetched( $links, '#/wp-admin/css/common(-rtl)?(\.min)?\.css#' );
+ $this->assertPrefetched( $links, 'style', '#/wp-admin/css/forms-rtl(\.min)?\.css#' );
+ }
+
+ /**
+ * Tests that each prefetched URL is sanitized only once, so that the {@see 'clean_url'} filter
+ * runs once for it, as it does for the URL in the tag the next screen prints.
+ *
+ * @ticket 57548
+ */
+ public function test_prefetched_urls_run_clean_url_filter_once(): void {
+ // The contexts the filter ran in, keyed by the URL it was given, before it was escaped.
+ $calls = array();
+ add_filter(
+ 'clean_url',
+ static function ( string $good_protocol_url, string $original_url, string $context ) use ( &$calls ): string {
+ $calls[ $original_url ][] = $context;
+ return $good_protocol_url;
+ },
+ 10,
+ 3
+ );
+
+ $links = $this->get_prefetched_on_login();
+ $this->assertNotSame( array(), $links );
+
+ foreach ( $links as $link ) {
+ $href = html_entity_decode( $link['href'], ENT_QUOTES );
+ $this->assertSame( array( 'display' ), $calls[ $href ] ?? array(), "Expected the 'clean_url' filter to run once for {$href}." );
+ }
+ }
+
+ /**
+ * Tests that stylesheet URLs reach the filter unescaped, like script URLs, and that a callback
+ * appending the escaped form of one, as built with esc_url(), is collapsed with it.
+ *
+ * @ticket 57548
+ */
+ public function test_filter_receives_unescaped_stylesheet_urls(): void {
+ // 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;
+ $escaped_href = null;
+ add_filter(
+ 'wp_prefetch_admin_assets',
+ static function ( array $resources ) use ( &$common_href, &$escaped_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'];
+ $escaped_href = esc_url( $resource['href'] );
+
+ // Append the escaped form of the same URL, as a callback building it with esc_url() would.
+ $resources[] = array(
+ 'href' => $escaped_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 );
+
+ $this->assertIsString( $escaped_href );
+ $this->assertStringContainsString( '&', $escaped_href, 'Expected the appended URL to differ from the one the filter received.' );
+
+ $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 escaped 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
+ */
+ public function test_filter_result_is_deduplicated_and_sanitized(): void {
+ add_filter(
+ 'wp_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',
+ ),
+ // Rejected by esc_url(), so it would otherwise be printed with an empty `href`.
+ array(
+ 'href' => 'javascript:alert(1)',
+ 'as' => 'script',
+ ),
+ // Allowed by esc_url() by default, but not prefetchable.
+ array(
+ 'href' => 'mailto:admin@example.com',
+ 'as' => 'document',
+ ),
+ array(
+ 'href' => 'ftp://example.com/file.js',
+ '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, in which case neither the prefetch links nor
+ * the script prefetching them in browsers that lack `rel="prefetch"` are printed.
+ *
+ * @ticket 57548
+ *
+ * @dataProvider data_filter_turning_off
+ *
+ * @param mixed $filtered Value the filter returns.
+ */
+ public function test_filter_can_turn_off_prefetching( $filtered ): void {
+ add_filter(
+ 'wp_prefetch_admin_assets',
+ static function () use ( $filtered ) {
+ return $filtered;
+ }
+ );
+
+ $this->assertSame( '', $this->get_login_footer_output() );
+ }
+
+ /**
+ * Tests that the script prefetching the links in browsers that lack `rel="prefetch"` is printed
+ * once, after all of the prefetch links it reads from the page.
+ *
+ * @ticket 57548
+ */
+ public function test_prints_polyfill_after_prefetch_links(): void {
+ $processor = new WP_HTML_Tag_Processor( $this->get_login_footer_output() );
+ $links = 0;
+ $scripts = array();
+ $links_after_js = 0;
+ while ( $processor->next_tag() ) {
+ if ( 'LINK' === $processor->get_tag() && 'prefetch' === $processor->get_attribute( 'rel' ) ) {
+ ++$links;
+ if ( $scripts ) {
+ ++$links_after_js;
+ }
+ } elseif ( 'SCRIPT' === $processor->get_tag() ) {
+ $scripts[] = $processor->get_modifiable_text();
+ }
+ }
+
+ $this->assertGreaterThan( 0, $links, 'Expected prefetch links to be printed.' );
+ $this->assertCount( 1, $scripts, 'Expected one script to be printed.' );
+ $this->assertSame( 0, $links_after_js, 'Expected the script to follow all of the prefetch links.' );
+ $this->assertStringContainsString( 'link[rel~="prefetch"]', $scripts[0] );
+ $this->assertStringContainsString( '//# sourceURL=wp_prefetch_admin_assets', $scripts[0] );
+ }
+
+ /**
+ * 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.
+ * @return list Prefetch links in the order printed.
+ */
+ private function get_prefetched_on_login( array $request = array() ): array {
+ return $this->parse_prefetch_links( $this->get_login_footer_output( $request ) );
+ }
+
+ /**
+ * Runs the login screen's prefetching and returns everything it printed.
+ *
+ * @param array $request Request parameters of the login screen.
+ * @return string Printed markup.
+ */
+ private function get_login_footer_output( array $request = array() ): string {
+ $_REQUEST = $request;
+
+ $this->go_to_login_screen();
+
+ remove_all_actions( 'login_footer' );
+ add_action( 'login_footer', 'wp_prefetch_admin_assets' );
+
+ return get_echo( 'do_action', array( 'login_footer' ) );
+ }
+
+ /**
+ * Makes the request one for the login screen, by firing 'login_init' as wp-login.php does.
+ *
+ * Its callbacks are removed first, since one of them sends headers. The request URI is that of
+ * wp-login.php, which a relative `redirect_to` is resolved against.
+ */
+ private function go_to_login_screen(): void {
+ $_SERVER['REQUEST_URI'] = (string) wp_parse_url( wp_login_url(), PHP_URL_PATH );
+
+ remove_all_actions( 'login_init' );
+
+ /** This action is documented in wp-login.php */
+ do_action( 'login_init' );
+ }
+
+ /**
+ * 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 'script'|'style' $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 'script'|'style' $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/wpShouldConcatenateAdminScripts.php b/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php
new file mode 100644
index 0000000000000..6030e3965314a
--- /dev/null
+++ b/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php
@@ -0,0 +1,134 @@
+original_concatenate_scripts = $GLOBALS['concatenate_scripts'] ?? null;
+ }
+
+ /**
+ * Restores the `$concatenate_scripts` global.
+ */
+ public function tear_down(): void {
+ if ( null === $this->original_concatenate_scripts ) {
+ unset( $GLOBALS['concatenate_scripts'] );
+ } else {
+ $GLOBALS['concatenate_scripts'] = $this->original_concatenate_scripts;
+ }
+
+ parent::tear_down();
+ }
+
+ /**
+ * Tests that `CONCATENATE_SCRIPTS` turns concatenation on, unless `SCRIPT_DEBUG` is on.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_constant_on(): void {
+ define( 'CONCATENATE_SCRIPTS', true );
+
+ $this->assertSame( ! SCRIPT_DEBUG, wp_should_concatenate_admin_scripts() );
+ }
+
+ /**
+ * Tests that `CONCATENATE_SCRIPTS` turns concatenation off.
+ *
+ * @ticket 57548
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_constant_off(): void {
+ define( 'CONCATENATE_SCRIPTS', false );
+
+ $this->assertFalse( wp_should_concatenate_admin_scripts() );
+ }
+
+ /**
+ * Tests the default when `CONCATENATE_SCRIPTS` is not defined, which is to concatenate unless
+ * `SCRIPT_DEBUG` is on.
+ *
+ * @ticket 57548
+ */
+ public function test_default(): void {
+ if ( defined( 'CONCATENATE_SCRIPTS' ) ) {
+ $this->markTestSkipped( 'CONCATENATE_SCRIPTS is defined.' );
+ }
+
+ $this->assertSame( ! SCRIPT_DEBUG, wp_should_concatenate_admin_scripts() );
+ }
+
+ /**
+ * Tests that the filter overrides the constants.
+ *
+ * @ticket 57548
+ */
+ public function test_filter(): void {
+ $filter = new MockAction();
+ add_filter( 'wp_should_concatenate_admin_scripts', array( $filter, 'filter' ) );
+
+ $this->assertSame( ! SCRIPT_DEBUG && ( ! defined( 'CONCATENATE_SCRIPTS' ) || CONCATENATE_SCRIPTS ), wp_should_concatenate_admin_scripts() );
+ $this->assertSame( 1, $filter->get_call_count() );
+
+ add_filter( 'wp_should_concatenate_admin_scripts', '__return_true', 20 );
+ $this->assertTrue( wp_should_concatenate_admin_scripts() );
+
+ add_filter( 'wp_should_concatenate_admin_scripts', '__return_false', 30 );
+ $this->assertFalse( wp_should_concatenate_admin_scripts() );
+ }
+
+ /**
+ * Tests that script_concat_settings() takes its default from the function on admin screens, and
+ * never concatenates elsewhere.
+ *
+ * @ticket 57548
+ *
+ * @covers ::script_concat_settings
+ */
+ public function test_script_concat_settings(): void {
+ add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' );
+
+ unset( $GLOBALS['concatenate_scripts'] );
+ script_concat_settings();
+ $this->assertFalse( $GLOBALS['concatenate_scripts'], 'Expected no concatenation on the front end.' );
+
+ set_current_screen( 'dashboard' );
+ unset( $GLOBALS['concatenate_scripts'] );
+ script_concat_settings();
+ $this->assertTrue( $GLOBALS['concatenate_scripts'], 'Expected concatenation on an admin screen.' );
+
+ add_filter( 'wp_should_concatenate_admin_scripts', '__return_false', 20 );
+ unset( $GLOBALS['concatenate_scripts'] );
+ script_concat_settings();
+ $this->assertFalse( $GLOBALS['concatenate_scripts'], 'Expected the filter to turn concatenation off on an admin screen.' );
+ }
+}
diff --git a/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php b/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php
new file mode 100644
index 0000000000000..c506c52490738
--- /dev/null
+++ b/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php
@@ -0,0 +1,260 @@
+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|null $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_src( '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, no version' => array( '/wp-admin/css/test.css', null, array( 'rtl' => true ), 'http://example.org/wp-admin/css/test-rtl.css' ),
+ 'rtl URL, no version' => array( '/wp-admin/css/test.css', null, array( 'rtl' => 'https://cdn.example.com/test-rtl.css' ), 'https://cdn.example.com/test-rtl.css' ),
+ '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_src( '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 right-to-left URL gets the same version and added arguments as the
+ * left-to-right one, rather than the arguments being encoded into the version.
+ *
+ * @ticket 57548
+ *
+ * @dataProvider data_matches_ltr_query
+ *
+ * @param string|false|null $ver Version the style is registered with.
+ */
+ public function test_matches_ltr_query( $ver ): void {
+ $this->styles->add( 'test', '/wp-admin/css/test.css', array(), $ver );
+ $this->styles->add_data( 'test', 'rtl', 'replace' );
+ $this->styles->enqueue( 'test?color=blue' );
+
+ $ltr_src = $this->styles->get_src( 'test' );
+ $this->assertStringContainsString( 'color=blue', $ltr_src );
+
+ $this->assertSame(
+ str_replace( 'test.css', 'test-rtl.css', $ltr_src ),
+ $this->styles->get_rtl_src( 'test' )
+ );
+ }
+
+ /**
+ * Data provider for {@see self::test_matches_ltr_query()}.
+ *
+ * @return array
+ */
+ public function data_matches_ltr_query(): array {
+ return array(
+ 'version' => array( '1.0' ),
+ 'default version' => array( false ),
+ 'no version' => array( null ),
+ );
+ }
+
+ /**
+ * 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_src( '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, once decoded from the
+ * attribute, 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 {
+ // A query string of its own, so the printed URL has an `&` that is escaped in the attribute.
+ $this->styles->add( 'test', '/wp-admin/css/test.css?color=blue', array(), '1.0' );
+ $this->styles->add_data( 'test', 'rtl', $rtl );
+
+ $output = get_echo( array( $this->styles, 'do_item' ), array( 'test' ) );
+
+ $this->assertSame( $expected, substr_count( $output, "rel='stylesheet'" ) );
+
+ $processor = new WP_HTML_Tag_Processor( $output );
+ $href = null;
+ while ( $processor->next_tag( 'LINK' ) ) {
+ if ( 'test-rtl-css' === $processor->get_attribute( 'id' ) ) {
+ $href = $processor->get_attribute( 'href' );
+ }
+ }
+
+ $this->assertSame( 'http://example.org/wp-admin/css/test-rtl.css?color=blue&ver=1.0', $href );
+ $this->assertSame( $href, $this->styles->get_rtl_src( 'test' ) );
+ }
+
+ /**
+ * Tests that printing a right-to-left stylesheet sanitizes its URL only once, so the
+ * {@see 'clean_url'} filter runs once for it, as it did before get_rtl_src() was introduced.
+ *
+ * @ticket 57548
+ *
+ * @covers WP_Styles::do_item
+ */
+ public function test_printing_runs_clean_url_filter_once(): void {
+ $this->styles->add( 'test', '/wp-admin/css/test.css', array(), '1.0' );
+ $this->styles->add_data( 'test', 'rtl', 'replace' );
+
+ $filter = new MockAction();
+ add_filter( 'clean_url', array( $filter, 'filter' ), 10, 3 );
+
+ get_echo( array( $this->styles, 'do_item' ), array( 'test' ) );
+
+ // Once for the left-to-right URL, which is built even when replaced, and once for the right-to-left one.
+ $this->assertSame(
+ array(
+ array( 'http://example.org/wp-admin/css/test.css?ver=1.0', 'display' ),
+ array( 'http://example.org/wp-admin/css/test-rtl.css?ver=1.0', 'display' ),
+ ),
+ array_map(
+ static function ( array $args ): array {
+ return array( $args[0], $args[2] );
+ },
+ $filter->get_args()
+ )
+ );
+ }
+
+ /**
+ * 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 ),
+ );
+ }
+}
diff --git a/tests/phpunit/tests/dependencies/wpStyles/getSrc.php b/tests/phpunit/tests/dependencies/wpStyles/getSrc.php
new file mode 100644
index 0000000000000..a6c70e6637a1e
--- /dev/null
+++ b/tests/phpunit/tests/dependencies/wpStyles/getSrc.php
@@ -0,0 +1,205 @@
+styles = new WP_Styles();
+ $this->styles->base_url = 'http://example.org';
+ $this->styles->content_url = '/wp-content';
+ $this->styles->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 style is registered with.
+ * @param string|false|null $ver Version the style is registered with.
+ * @param string $expected Expected URL.
+ */
+ public function test_builds_url( string $src, $ver, string $expected ): void {
+ $this->styles->add( 'test', $src, array(), $ver );
+
+ $this->assertSame( $expected, $this->styles->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-admin/css/test.css', false, 'http://example.org/wp-admin/css/test.css?ver=7.2' ),
+ 'relative source, explicit version' => array( '/wp-admin/css/test.css', '1.0', 'http://example.org/wp-admin/css/test.css?ver=1.0' ),
+ 'relative source, no version' => array( '/wp-admin/css/test.css', null, 'http://example.org/wp-admin/css/test.css' ),
+ 'absolute source' => array( 'https://cdn.example.com/test.css', '1.0', 'https://cdn.example.com/test.css?ver=1.0' ),
+ 'protocol-relative source' => array( '//cdn.example.com/test.css', '1.0', '//cdn.example.com/test.css?ver=1.0' ),
+ 'source under the content URL' => array( '/wp-content/plugins/test/test.css', '1.0', '/wp-content/plugins/test/test.css?ver=1.0' ),
+ 'source with a query string' => array( 'https://cdn.example.com/test.css?a=1', '1.0', 'https://cdn.example.com/test.css?a=1&ver=1.0' ),
+ 'source with a fragment' => array( 'https://cdn.example.com/test.css#frag', '1.0', 'https://cdn.example.com/test.css?ver=1.0#frag' ),
+ 'source with a fragment and no version' => array( 'https://cdn.example.com/test.css#frag', null, 'https://cdn.example.com/test.css#frag' ),
+ 'version needing to be encoded' => array( 'https://cdn.example.com/test.css', '1.0 beta', 'https://cdn.example.com/test.css?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->styles->add( 'test', 'https://cdn.example.com/test.css#frag', array(), '1.0' );
+ $this->styles->all_deps( 'test?a=1&b=2' );
+
+ $this->assertSame( 'https://cdn.example.com/test.css?ver=1.0&a=1&b=2#frag', $this->styles->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->styles->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->styles->add( 'dependency', '/wp-admin/css/dependency.css' );
+ $this->styles->add( 'alias', false, array( 'dependency' ) );
+
+ $this->assertSame( '', $this->styles->get_src( 'alias' ) );
+ }
+
+ /**
+ * Tests that a handle registered with `true` as its source, as `colors` is, gets its URL from the
+ * {@see 'style_loader_src'} filter.
+ *
+ * @ticket 57548
+ */
+ public function test_uses_filter_for_source_of_true(): void {
+ $this->styles->add( 'test', true ); // @phpstan-ignore argument.type (Core registers `colors` this way, though the add() docblock does not allow `true`.)
+
+ add_filter(
+ 'style_loader_src',
+ static function ( $src, string $handle ) {
+ return 'test' === $handle ? 'https://cdn.example.com/scheme.css' : $src;
+ },
+ 10,
+ 2
+ );
+
+ $this->assertSame( 'https://cdn.example.com/scheme.css', $this->styles->get_src( 'test' ) );
+ }
+
+ /**
+ * Tests that the URL is passed through the {@see 'style_loader_src'} filter along with the handle.
+ *
+ * @ticket 57548
+ */
+ public function test_applies_style_loader_src_filter(): void {
+ $this->styles->add( 'test', '/wp-admin/css/test.css', array(), '1.0' );
+
+ $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' === $handle ? str_replace( 'example.org', 'cdn.example.com', $src ) : $src;
+ },
+ 20,
+ 2
+ );
+
+ $this->assertSame( 'http://cdn.example.com/wp-admin/css/test.css?ver=1.0', $this->styles->get_src( 'test' ) );
+ $this->assertSame(
+ array( 'http://example.org/wp-admin/css/test.css?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 'style_loader_src'} filter returns.
+ */
+ public function test_returns_empty_string_when_filtered_away( $filtered ): void {
+ $this->styles->add( 'test', '/wp-admin/css/test.css' );
+
+ add_filter(
+ 'style_loader_src',
+ static function () use ( $filtered ) {
+ return $filtered;
+ }
+ );
+
+ $this->assertSame( '', $this->styles->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_Styles::do_item()} prints, once decoded from the
+ * attribute, and that {@see WP_Styles::_css_href()} still returns it escaped for the attribute.
+ *
+ * @ticket 57548
+ */
+ public function test_matches_printed_href(): void {
+ $this->styles->add( 'test', 'https://cdn.example.com/test.css#frag', array(), '1.0' );
+ $this->styles->all_deps( 'test?a=1&b=2' );
+
+ $output = get_echo( array( $this->styles, 'do_item' ), array( 'test' ) );
+
+ $processor = new WP_HTML_Tag_Processor( $output );
+ $this->assertTrue( $processor->next_tag( 'LINK' ) );
+ $this->assertSame( $this->styles->get_src( 'test' ), $processor->get_attribute( 'href' ) );
+
+ $this->assertStringContainsString( "href='https://cdn.example.com/test.css?ver=1.0&a=1&b=2#frag'", $output );
+ $this->assertSame(
+ 'https://cdn.example.com/test.css?ver=1.0&a=1&b=2#frag',
+ $this->styles->_css_href( 'https://cdn.example.com/test.css#frag', '1.0', 'test' )
+ );
+ }
+}