diff --git a/src/wp-admin/admin-header.php b/src/wp-admin/admin-header.php index 265c91923eea9..bfa84a11d8c8c 100644 --- a/src/wp-admin/admin-header.php +++ b/src/wp-admin/admin-header.php @@ -95,6 +95,10 @@ <?php echo esc_html( $admin_title ); ?> 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' ) + ); + } +}