diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 12ca0045b98b4..69b73a3bbfdb6 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -393,6 +393,7 @@ 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', 10 ); 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/script-loader.php b/src/wp-includes/script-loader.php index a364439f0abbb..5eff768f6272f 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2497,6 +2497,306 @@ function script_concat_settings() { } } +/** + * Resolves a registered script or style handle to the URL it would be loaded from. + * + * Mirrors how {@see WP_Scripts::do_item()} and {@see WP_Styles::do_item()} build the + * URL they print, including the version query argument and the {@see 'script_loader_src'} + * and {@see 'style_loader_src'} filters, without printing anything or disturbing the queue. + * + * @since 7.2.0 + * @access private + * + * @param WP_Scripts|WP_Styles $dependencies Registry to look the handle up in. + * @param string $handle Handle to resolve. + * @return string[] URLs the handle resolves to. Empty when the handle is not registered, + * aliases other handles without a source of its own, or is filtered away. + * + * @phpstan-return list + */ +function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { + if ( ! isset( $dependencies->registered[ $handle ] ) ) { + return array(); + } + + $obj = $dependencies->registered[ $handle ]; + + // A handle may alias a set of other handles by having dependencies but no source. + if ( empty( $obj->src ) || ! is_string( $obj->src ) ) { + return array(); + } + + if ( $dependencies instanceof WP_Styles ) { + $href = $dependencies->_css_href( $obj->src, $obj->ver, $handle ); + + if ( ! is_string( $href ) || '' === $href ) { + return array(); + } + + $urls = array( $href ); + + /* + * On RTL locales a handle may be served by a separate stylesheet, either replacing + * the LTR one or loading alongside it. Follow the same rules WP_Styles::do_item() uses. + */ + if ( 'rtl' === $dependencies->text_direction && ! empty( $obj->extra['rtl'] ) ) { + if ( null === $obj->ver ) { + $ver = ''; + } else { + $ver = $obj->ver ? $obj->ver : $dependencies->default_version; + } + + if ( isset( $dependencies->args[ $handle ] ) ) { + $ver = $ver ? $ver . '&' . $dependencies->args[ $handle ] : $dependencies->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'] : ''; + $rtl_href = str_replace( "{$suffix}.css", "-rtl{$suffix}.css", $dependencies->_css_href( $obj->src, $ver, "$handle-rtl" ) ); + } elseif ( is_string( $obj->extra['rtl'] ) ) { + $rtl_href = $dependencies->_css_href( $obj->extra['rtl'], $ver, "$handle-rtl" ); + } else { + $rtl_href = ''; + } + + if ( is_string( $rtl_href ) && '' !== $rtl_href ) { + if ( 'replace' === $obj->extra['rtl'] ) { + $urls = array( $rtl_href ); + } else { + $urls[] = $rtl_href; + } + } + } + + return $urls; + } + + $src = $obj->src; + + if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $dependencies->content_url && str_starts_with( $src, $dependencies->content_url ) ) ) { + $src = $dependencies->base_url . $src; + } + + $ver_to_add = ''; + if ( empty( $obj->ver ) && null !== $obj->ver && is_string( $dependencies->default_version ) ) { + $ver_to_add = $dependencies->default_version; + } elseif ( is_scalar( $obj->ver ) ) { + $ver_to_add = (string) $obj->ver; + } + + if ( '' !== $ver_to_add ) { + $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . 'ver=' . rawurlencode( $ver_to_add ); + } + + /** This filter is documented in wp-includes/class-wp-scripts.php */ + $src = esc_url_raw( apply_filters( 'script_loader_src', $src, $handle ) ); + + if ( ! is_string( $src ) || '' === $src ) { + return array(); + } + + return array( $src ); +} + +/** + * Prints prefetch links on the login screen for the admin assets that concatenation would bundle. + * + * With concatenation disabled the first admin screen after logging in downloads each of these + * files separately, which is what makes an uncached admin load slower than a concatenated one. + * Requesting them 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. + * + * These are resources for the *next* navigation rather than for the login screen itself, which is + * what `rel="prefetch"` describes. `rel="preload"` would fetch them at the current document's + * priority and make cross-navigation reuse depend entirely on the static files' HTTP cache headers, + * which core does not control; browsers also 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 + * the login screen's own render-blocking assets without needing `fetchpriority`. + * + * 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. + * + * Every handle covered here loads on all admin screens rather than only on the Dashboard, so 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 and logout flows, on an + * interim login, or when `redirect_to` points outside the 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() + */ +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; + } + + /* + * 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. + */ + $login_action = isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) + ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) + : 'login'; + + if ( 'login' !== $login_action || isset( $_REQUEST['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. + */ + $redirect_to = admin_url(); + + if ( isset( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) { + $redirect_to = 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( $redirect_to, PHP_URL_PATH ), $admin_path ) ) { + return; + } + + /* + * The handles that load-scripts.php and load-styles.php concatenate on an admin screen. + * Every handle listed here 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` is concatenated on the Dashboard but nowhere else. + * Handles registered without a source of their own, or not registered at all, are skipped. + */ + $script_handles = array( + 'jquery-core', + 'jquery-migrate', + 'utils', + 'hoverIntent', + 'wp-dom-ready', + 'wp-hooks', + ); + + $style_handles = array( + 'dashicons', + 'admin-bar', + 'common', + 'forms', + 'admin-menu', + 'dashboard', + 'list-tables', + 'edit', + 'revisions', + 'media', + 'themes', + 'about', + 'nav-menus', + 'wp-pointer', + 'widgets', + 'site-icon', + 'l10n', + 'wp-base-styles', + 'wp-tooltip', + 'buttons', + 'wp-auth-check', + 'wp-theme', + 'wp-components', + 'wp-commands', + ); + + $resources = array(); + + foreach ( array( + 'script' => $script_handles, + 'style' => $style_handles, + ) as $as => $handles ) { + $dependencies = 'script' === $as ? wp_scripts() : wp_styles(); + + foreach ( $handles as $handle ) { + /* + * The login screen shares a handful 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; + } + + foreach ( _wp_resolve_dependency_urls( $dependencies, $handle ) as $url ) { + $resources[ $url ] = array( + 'href' => $url, + 'as' => $as, + ); + } + } + } + + /** + * Filters the admin assets prefetched on the login screen. + * + * Only the `href` and `as` attributes below are printed; any other key is ignored. + * Returning an empty array turns the prefetching off. + * + * @since 7.2.0 + * + * @param array $resources { + * Resources to prefetch, keyed by URL. + * + * @type array ...$0 { + * @type string $href URL to prefetch. + * @type string $as How the browser should treat the resource. + * } + * } + * @param string $redirect_to URL the login will redirect to, already run through + * wp_validate_redirect() with the admin as the fallback. Always + * points into the admin, since nothing is prefetched otherwise, + * but may be relative: this 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( 'login_prefetch_admin_assets', $resources, $redirect_to ); + + if ( ! is_array( $resources ) ) { + return; + } + + 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; + } + + 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.