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