From b3809c846db3d16fa99e915ea9d5c5cebd1d6de0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 16 Aug 2026 14:36:54 -0700 Subject: [PATCH 01/35] Preload the admin's unconcatenated assets from the login screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When script and style concatenation is disabled, the first admin screen after logging in downloads each core script and stylesheet separately. Measured on a throttled Fast 4G connection with a cold cache, that costs roughly 600 ms of First Contentful Paint against the concatenated equivalent: 28 extra requests that HTTP/1.1 has to serialize behind its six-connection cap. Print `link rel=preload` tags on the login screen for the handles that `load-scripts.php` and `load-styles.php` would otherwise bundle, so the browser puts them in the HTTP cache while the login form is on screen rather than after the redirect. The tags carry `fetchpriority=low` so they queue behind the login screen's own render-blocking assets, and handles the login screen has already printed are skipped. Add `_wp_resolve_dependency_urls()` to resolve a registered handle to the URL it would load from, mirroring how `WP_Scripts::do_item()` and `WP_Styles::do_item()` build it — the version argument, the `script_loader_src` and `style_loader_src` filters, and the RTL replace-or-append rules — without printing anything or disturbing the queue. Gate on `CONCATENATE_SCRIPTS && ! SCRIPT_DEBUG` rather than on the `$concatenate_scripts` global. `script_concat_settings()` usually runs on a login request before `login_init` fires, since registering any 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. Co-Authored-By: Claude Opus 5 --- src/wp-includes/default-filters.php | 1 + src/wp-includes/script-loader.php | 253 ++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 12ca0045b98b4..31be545439b85 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_preload_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..cf572864a55b4 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2497,6 +2497,259 @@ 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 preload 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 time the + * user spends typing credentials, so the redirect that follows finds them already there. + * + * The links carry `fetchpriority="low"` so they queue behind the login screen's own + * render-blocking assets rather than competing with them. + * + * 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_preload_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; + } + + /* + * The handles that load-scripts.php and load-styles.php concatenate on an admin screen. + * 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', + 'site-health', + '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. Preloading 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, + 'fetchpriority' => 'low', + ); + } + } + } + + /** + * Filters the admin assets preloaded on the login screen. + * + * Accepts the same resource attributes as the {@see 'wp_preload_resources'} filter. + * Returning an empty array turns the preloading off. + * + * @since 7.2.0 + * + * @param array $resources { + * Resources to preload, keyed by URL. + * + * @type array ...$0 { + * @type string $href URL to preload. + * @type string $as How the browser should treat the resource. + * @type string $fetchpriority Fetchpriority value for the resource. + * } + * } + */ + $resources = apply_filters( 'login_preload_admin_assets', $resources ); + + 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; + } + + $fetchpriority = $resource['fetchpriority'] ?? 'low'; + if ( ! is_string( $fetchpriority ) ) { + $fetchpriority = 'low'; + } + + printf( + "\n", + esc_url( $href ), + esc_attr( $as ), + esc_attr( $fetchpriority ) + ); + } +} + /** * Handles the enqueueing of block scripts and styles that are common to both * the editor and the front-end. From 87893516fd38c01554f32be8cfc3415ef368b1a6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 16 Aug 2026 15:28:22 -0700 Subject: [PATCH 02/35] Prefetch rather than preload the admin's assets from the login screen The assets these links point at are for the navigation that follows the login, not for the login screen itself, and `rel="prefetch"` is what describes that. Using `rel="preload"` had three consequences worth avoiding: it fetches at the current document's priority rather than idle priority, it makes cross-navigation reuse depend entirely on the static files' HTTP cache headers, which core does not control, and it makes browsers warn about every preloaded resource the document never goes on to use. Rename `wp_preload_admin_assets()` to `wp_prefetch_admin_assets()` and the `login_preload_admin_assets` filter to `login_prefetch_admin_assets` to match. Keep the `as` attribute, which is what lets a prefetched response be reused for a request with the same destination, and keep `fetchpriority="low"`. Also narrow the filter's documented contract. It claimed to accept the same resource attributes as the `wp_preload_resources` filter, but only `href`, `as` and `fetchpriority` are ever printed. Co-Authored-By: Claude Opus 5 --- src/wp-includes/default-filters.php | 2 +- src/wp-includes/script-loader.php | 32 ++++++++++++++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 31be545439b85..69b73a3bbfdb6 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -393,7 +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_preload_admin_assets', 10 ); +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 cf572864a55b4..b98f1fde91d54 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2599,14 +2599,18 @@ function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { } /** - * Prints preload links on the login screen for the admin assets that concatenation would bundle. + * 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 time the - * user spends typing credentials, so the redirect that follows finds them already there. - * - * The links carry `fetchpriority="low"` so they queue behind the login screen's own + * 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. + * The links additionally carry `fetchpriority="low"` so they queue behind the login screen's own * render-blocking assets rather than competing with them. * * Nothing is printed when concatenation is enabled, since `load-scripts.php` and @@ -2616,7 +2620,7 @@ function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { * * @see wp_preload_resources() */ -function wp_preload_admin_assets(): void { +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 @@ -2683,7 +2687,7 @@ function wp_preload_admin_assets(): void { 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. Preloading them again + * 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 ) ) { @@ -2701,24 +2705,24 @@ function wp_preload_admin_assets(): void { } /** - * Filters the admin assets preloaded on the login screen. + * Filters the admin assets prefetched on the login screen. * - * Accepts the same resource attributes as the {@see 'wp_preload_resources'} filter. - * Returning an empty array turns the preloading off. + * Only the `href`, `as` and `fetchpriority` 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 preload, keyed by URL. + * Resources to prefetch, keyed by URL. * * @type array ...$0 { - * @type string $href URL to preload. + * @type string $href URL to prefetch. * @type string $as How the browser should treat the resource. * @type string $fetchpriority Fetchpriority value for the resource. * } * } */ - $resources = apply_filters( 'login_preload_admin_assets', $resources ); + $resources = apply_filters( 'login_prefetch_admin_assets', $resources ); if ( ! is_array( $resources ) ) { return; @@ -2742,7 +2746,7 @@ function wp_preload_admin_assets(): void { } printf( - "\n", + "\n", esc_url( $href ), esc_attr( $as ), esc_attr( $fetchpriority ) From 879ce152980137c0451d2c626371505084c62fde Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 16 Aug 2026 15:33:24 -0700 Subject: [PATCH 03/35] Drop the redundant fetchpriority attribute from the admin prefetch links A prefetch is already dispatched at the browser's lowest priority, so `fetchpriority="low"` has nothing left to lower. The attribute is defined for use with external resource links, where it sets the priority for fetching and processing the linked resource, and browsers wire it up for `preload`, `modulepreload`, scripts, images and iframes rather than for `prefetch`. Printing it here implied a control that was not being exercised. The `as` attribute stays. It gives the request the same destination the admin screen will later ask for, which is what allows the prefetched response to be reused. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index b98f1fde91d54..64ab313453a14 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2610,8 +2610,11 @@ function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { * 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. - * The links additionally carry `fetchpriority="low"` so they queue behind the login screen's own - * render-blocking assets rather than competing with them. + * 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. * * Nothing is printed when concatenation is enabled, since `load-scripts.php` and * `load-styles.php` already collapse these handles into a handful of requests. @@ -2696,9 +2699,8 @@ function wp_prefetch_admin_assets(): void { foreach ( _wp_resolve_dependency_urls( $dependencies, $handle ) as $url ) { $resources[ $url ] = array( - 'href' => $url, - 'as' => $as, - 'fetchpriority' => 'low', + 'href' => $url, + 'as' => $as, ); } } @@ -2707,8 +2709,8 @@ function wp_prefetch_admin_assets(): void { /** * Filters the admin assets prefetched on the login screen. * - * Only the `href`, `as` and `fetchpriority` attributes below are printed; any other key is - * ignored. Returning an empty array turns the prefetching off. + * 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 * @@ -2716,9 +2718,8 @@ function wp_prefetch_admin_assets(): void { * 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. - * @type string $fetchpriority Fetchpriority value for the resource. + * @type string $href URL to prefetch. + * @type string $as How the browser should treat the resource. * } * } */ @@ -2740,16 +2741,10 @@ function wp_prefetch_admin_assets(): void { continue; } - $fetchpriority = $resource['fetchpriority'] ?? 'low'; - if ( ! is_string( $fetchpriority ) ) { - $fetchpriority = 'low'; - } - printf( - "\n", + "\n", esc_url( $href ), - esc_attr( $as ), - esc_attr( $fetchpriority ) + esc_attr( $as ) ); } } From 98b2ee1448aeb4d684b2e7e93908f46980b08b21 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 16 Aug 2026 15:51:32 -0700 Subject: [PATCH 04/35] Only prefetch admin assets when the login actually leads to the admin 'login_head' fires for every login-family screen, not just the login form, and a successful login does not necessarily land on an admin screen. Prefetching in those cases spends the visitor's bandwidth on files they will never request. Skip the prefetching entirely on the password reset, registration, logout confirmation and check-your-email flows, on an interim login, which re-authenticates inside a modal on a page that already has these assets, and when `redirect_to` points outside the admin. An off-host `redirect_to` still prefetches, because `wp_safe_redirect()` falls back to the admin in that case and `wp_validate_redirect()` is used here to mirror that. The set of handles itself does not need to vary with the destination. Every handle listed loads on all admin screens rather than only on the Dashboard, since `wp-admin` is an alias handle enqueued everywhere that pulls in `dashboard`, `edit`, `themes`, `nav-menus` and the rest. Verified across the Dashboard, Posts, Add New Post, Media, Plugins, Settings, Profile and Themes: all 6 scripts and 24 of the 25 styles appear on every one. Drop the exception. `site-health` is concatenated on the Dashboard and nowhere else, so it is the one handle that was tied to a particular screen. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 38 ++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 64ab313453a14..107a5dbb1b22b 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2616,6 +2616,11 @@ function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { * 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. * @@ -2638,8 +2643,40 @@ function wp_prefetch_admin_assets(): void { 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; + } + + /* + * A successful login lands on `redirect_to` when one was given, and on the admin otherwise. + * When it points somewhere else, such as the front end or a plugin's own screen, none of + * these assets are wanted. wp_validate_redirect() mirrors what wp_safe_redirect() will do + * with a value pointing off-host, which is to fall back to the admin. + */ + if ( isset( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) { + $destination = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ), admin_url() ); + $admin_path = (string) wp_parse_url( admin_url(), PHP_URL_PATH ); + + if ( '' === $admin_path || ! str_starts_with( (string) wp_parse_url( $destination, 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( @@ -2654,7 +2691,6 @@ function wp_prefetch_admin_assets(): void { $style_handles = array( 'dashicons', 'admin-bar', - 'site-health', 'common', 'forms', 'admin-menu', From 0db2f11f8791096c3180a6078f79fdd3c22ea234 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 16 Aug 2026 16:14:06 -0700 Subject: [PATCH 05/35] Pass the login redirect target to the admin asset prefetch filter A plugin adjusting the prefetched set almost always wants to know where the login is about to land, and without it being handed over the only way to find out is to read `redirect_to` back out of `$_REQUEST` and repeat the validation this function has already done. Pass the resolved destination as a second argument to `login_prefetch_admin_assets`. It is the value wp_safe_redirect() will receive: `redirect_to` when the request supplied one, the admin otherwise, already through wp_validate_redirect() so an off-host value has fallen back to the admin. Resolve it unconditionally rather than only when the request carries the argument, so the filter gets a usable value in the common case where it does not. The docblock notes that it may be relative, since a request-supplied path is passed through unchanged and only the fallback is a full URL. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 34 +++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 107a5dbb1b22b..5eff768f6272f 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2658,18 +2658,24 @@ function wp_prefetch_admin_assets(): void { } /* - * A successful login lands on `redirect_to` when one was given, and on the admin otherwise. - * When it points somewhere else, such as the front end or a plugin's own screen, none of - * these assets are wanted. wp_validate_redirect() mirrors what wp_safe_redirect() will do - * with a value pointing off-host, which is to fall back to the admin. + * 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'] ) ) { - $destination = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ), admin_url() ); - $admin_path = (string) wp_parse_url( admin_url(), PHP_URL_PATH ); + $redirect_to = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ), admin_url() ); + } - if ( '' === $admin_path || ! str_starts_with( (string) wp_parse_url( $destination, PHP_URL_PATH ), $admin_path ) ) { - return; - } + /* + * 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; } /* @@ -2750,7 +2756,7 @@ function wp_prefetch_admin_assets(): void { * * @since 7.2.0 * - * @param array $resources { + * @param array $resources { * Resources to prefetch, keyed by URL. * * @type array ...$0 { @@ -2758,8 +2764,14 @@ function wp_prefetch_admin_assets(): void { * @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 ); + $resources = apply_filters( 'login_prefetch_admin_assets', $resources, $redirect_to ); if ( ! is_array( $resources ) ) { return; From fa3677e7ab3fea566c48986972562b910d41a299 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 17 Aug 2026 13:43:12 -0700 Subject: [PATCH 06/35] Prefetch the editor's stylesheets from the screens that lead to it The login screen is not the only place the next screen can be guessed at. From the Dashboard and the post list tables the editor is the usual next stop, and it is by far the heaviest screen in the admin: landing there from the Dashboard pulls in 47 files the Dashboard did not already have. Prefetch the editor's stylesheets from those screens, and from the login screen as well when `redirect_to` points at `post-new.php` or at `post.php` with `action=edit`. Because handles the current screen has already printed are skipped, each context only fetches what it is actually adding: 18 stylesheets from the Dashboard or a post list, and those plus the admin-wide set from the login screen. Stylesheets only. The editor's scripts come to roughly 1.26 MB compressed against 98 KB for its stylesheets, which is far too much to spend speculatively on a screen the user may never open. The stylesheets are render-blocking and land in the same size class as the login screen's existing prefetch. Name the roots rather than the whole set. `_wp_expand_dependency_handles()` pulls in whatever those roots depend on, so the list follows the dependencies declared in `wp_default_styles()` instead of restating them: eight roots cover all eighteen handles, and `wp-edit-post` alone accounts for most of the editor chrome. Skip the whole thing for a user who cannot create the post type, and for a post type still using the classic editor, which would load none of these. Rename the filter from `login_prefetch_admin_assets` to `prefetch_admin_assets`, since it is no longer login-specific, and describe its second argument as the screen being prefetched for rather than as a redirect target. Co-Authored-By: Claude Opus 5 --- src/wp-includes/default-filters.php | 1 + src/wp-includes/script-loader.php | 315 ++++++++++++++++++++-------- 2 files changed, 232 insertions(+), 84 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 69b73a3bbfdb6..3f132608fa2be 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -394,6 +394,7 @@ 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( 'admin_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 5eff768f6272f..1ca485a5adf21 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2599,12 +2599,89 @@ function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { } /** - * Prints prefetch links on the login screen for the admin assets that concatenation would bundle. + * Expands a set of handles to include everything they depend on. * - * 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. + * Lets a caller name a few roots instead of restating a dependency tree that is already declared + * at registration, so the set keeps up with changes to those declarations on its own. + * + * @since 7.2.0 + * @access private + * + * @param WP_Scripts|WP_Styles $dependencies Registry to resolve the handles against. + * @param string[] $handles Root handles to expand. + * @return string[] The roots together with everything they depend on, roots first. Handles that + * are not registered are dropped, as are their dependencies. + * + * @phpstan-return list + */ +function _wp_expand_dependency_handles( $dependencies, array $handles ): array { + $expanded = array(); + $queue = array_values( $handles ); + + while ( $queue ) { + $handle = array_shift( $queue ); + + if ( isset( $expanded[ $handle ] ) || ! isset( $dependencies->registered[ $handle ] ) ) { + continue; + } + + $expanded[ $handle ] = true; + + foreach ( $dependencies->registered[ $handle ]->deps as $dependency ) { + if ( is_string( $dependency ) && ! isset( $expanded[ $dependency ] ) ) { + $queue[] = $dependency; + } + } + } + + return array_keys( $expanded ); +} + +/** + * Determines whether a URL points at the block editor. + * + * @since 7.2.0 + * @access private + * + * @param string $url URL to examine. + * @return bool Whether loading the URL would open the block editor. + */ +function _wp_prefetch_target_is_block_editor( string $url ): bool { + $file = basename( (string) wp_parse_url( $url, PHP_URL_PATH ) ); + + if ( 'post-new.php' === $file ) { + return true; + } + + if ( 'post.php' !== $file ) { + return false; + } + + // post.php also handles trashing, restoring and bulk edits, none of which loads the editor. + $query = array(); + wp_parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); + + return isset( $query['action'] ) && 'edit' === $query['action']; +} + +/** + * 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 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. + * - 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 login screen itself, which is * what `rel="prefetch"` describes. `rel="preload"` would fetch them at the current document's @@ -2616,10 +2693,10 @@ function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { * 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. + * 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 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. @@ -2643,83 +2720,148 @@ function wp_prefetch_admin_assets(): void { 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'; + $on_login = ( 'login_head' === current_action() ); + $script_handles = array(); + $style_handles = array(); - if ( 'login' !== $login_action || isset( $_REQUEST['interim-login'] ) ) { - return; - } + 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. + */ + $login_action = isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) + ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) + : '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_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 ( 'login' !== $login_action || isset( $_REQUEST['interim-login'] ) ) { + return; + } - 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() ); - } + /* + * 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(); - /* - * 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 ( isset( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) { + $next_screen = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ), admin_url() ); + } - if ( '' === $admin_path || ! str_starts_with( (string) wp_parse_url( $redirect_to, PHP_URL_PATH ), $admin_path ) ) { - return; + /* + * 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; + } + } 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; + } + + $create_posts = $post_type_object->cap->create_posts ?? ''; + + /* + * 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 ( ! is_string( $create_posts ) + || ! current_user_can( $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' ) ); } - /* - * 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', - ); + if ( $on_login ) { + /* + * 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', - ); + $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', + ); + } + + if ( _wp_prefetch_target_is_block_editor( $next_screen ) ) { + /* + * Roots rather than the full set: everything these depend on is pulled in with them, so the + * list follows the dependencies declared in wp_default_styles() instead of restating them. + * `wp-edit-post` alone accounts for most of the editor chrome; the rest cover the media + * modal, the block directory, the format library and the editor's own reset. + */ + $style_handles = array_merge( + $style_handles, + _wp_expand_dependency_handles( + wp_styles(), + array( + 'wp-edit-post', + 'wp-block-editor-content', + 'wp-block-directory', + 'wp-format-library', + 'wp-reset-editor-styles', + 'editor-buttons', + 'media-views', + 'imgareaselect', + ) + ) + ); + } $resources = array(); @@ -2749,7 +2891,11 @@ function wp_prefetch_admin_assets(): void { } /** - * Filters the admin assets prefetched on the login screen. + * 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. * Returning an empty array turns the prefetching off. @@ -2764,14 +2910,15 @@ function wp_prefetch_admin_assets(): void { * @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() + * @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( 'login_prefetch_admin_assets', $resources, $redirect_to ); + $resources = apply_filters( 'prefetch_admin_assets', $resources, $next_screen ); if ( ! is_array( $resources ) ) { return; From cefa78d699a85a03402a638fab441e3e758f0e1f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 18 Aug 2026 13:56:19 -0700 Subject: [PATCH 07/35] Type the handle expander against WP_Dependencies Expanding a set of handles to include their dependencies reads nothing beyond the registry's `registered` array and each item's `deps`, both of which are declared on `WP_Dependencies` itself. Naming the two subclasses in the signature therefore claimed more than the function needs, and turned away any other registry that would work just as well. Since the parameter now names a single class rather than a union, it also gains a native type hint. `_wp_resolve_dependency_urls()` keeps its `WP_Scripts|WP_Styles` union: it reaches for `_css_href()`, `text_direction`, `base_url`, `content_url`, and `default_version`, none of which the base class declares, and the union is what gives its `instanceof WP_Styles` branch something to narrow. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 1ca485a5adf21..151c6aee2fcd6 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2607,14 +2607,14 @@ function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { * @since 7.2.0 * @access private * - * @param WP_Scripts|WP_Styles $dependencies Registry to resolve the handles against. - * @param string[] $handles Root handles to expand. + * @param WP_Dependencies $dependencies Registry to resolve the handles against. + * @param string[] $handles Root handles to expand. * @return string[] The roots together with everything they depend on, roots first. Handles that * are not registered are dropped, as are their dependencies. * * @phpstan-return list */ -function _wp_expand_dependency_handles( $dependencies, array $handles ): array { +function _wp_expand_dependency_handles( WP_Dependencies $dependencies, array $handles ): array { $expanded = array(); $queue = array_values( $handles ); From 814fcd72c35bbade2ebbcea4bd9f082135b9af85 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 18 Aug 2026 16:03:42 -0700 Subject: [PATCH 08/35] Keep empty handles out of the expanded dependency set The roots handed to the handle expander are non-empty by contract, but the handles reached through them are only ever known to be strings: the `deps` property is documented as `string[]`, so an empty one would be queued, used as an array key, and handed back to the caller as an empty handle to resolve. Excluding it where a non-root handle enters the queue is the only place the check is needed, and it lets the signature say what the function actually returns: a list of non-empty handles, expanded from a non-empty list of them. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 151c6aee2fcd6..c4882fe58c710 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2612,7 +2612,8 @@ function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { * @return string[] The roots together with everything they depend on, roots first. Handles that * are not registered are dropped, as are their dependencies. * - * @phpstan-return list + * @phpstan-param non-empty-list $handles + * @phpstan-return list */ function _wp_expand_dependency_handles( WP_Dependencies $dependencies, array $handles ): array { $expanded = array(); @@ -2628,7 +2629,7 @@ function _wp_expand_dependency_handles( WP_Dependencies $dependencies, array $ha $expanded[ $handle ] = true; foreach ( $dependencies->registered[ $handle ]->deps as $dependency ) { - if ( is_string( $dependency ) && ! isset( $expanded[ $dependency ] ) ) { + if ( is_string( $dependency ) && '' !== $dependency && ! isset( $expanded[ $dependency ] ) ) { $queue[] = $dependency; } } From 6ad32b5b196acbb3b7d2332679bc76676c593138 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 18 Aug 2026 16:16:53 -0700 Subject: [PATCH 09/35] Simplify cap check --- src/wp-includes/script-loader.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index c4882fe58c710..79020d2854eb9 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2778,15 +2778,13 @@ function wp_prefetch_admin_assets(): void { return; } - $create_posts = $post_type_object->cap->create_posts ?? ''; - /* * 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 ( ! is_string( $create_posts ) - || ! current_user_can( $create_posts ) - || ! use_block_editor_for_post_type( $post_type ) + if ( + ! current_user_can( $post_type_object->cap->create_posts ) || + ! use_block_editor_for_post_type( $post_type ) ) { return; } From d80705e0cf898dedb580db7734cc24c6dbf7c0b5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 18 Aug 2026 17:02:33 -0700 Subject: [PATCH 10/35] Model the prefetch resource list on wp_preload_resources() The prefetched resources were keyed by URL, which collapsed handles resolving to the same file but left the filter looking at a map whose keys repeated the `href` beside them. Worse, it put the collapsing before the filter rather than after, so a callback appending a URL core had already listed would have printed a second link for it. `wp_preload_resources()` had already settled all of this: the filter sees a plain list of attribute arrays, duplicates are folded afterwards into a set keyed by `href` with the first entry winning, and printing walks that set. Doing the same here means a callback can append without first checking what is already there, and anyone who has read one filter has read the other. Printing stays a fixed `href`/`as` pair rather than the generic walk over an attribute allowlist, since those two are the whole contract and `fetchpriority` was deliberately dropped from these links earlier. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 79020d2854eb9..8ebd7542ca1b7 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2881,7 +2881,7 @@ function wp_prefetch_admin_assets(): void { } foreach ( _wp_resolve_dependency_urls( $dependencies, $handle ) as $url ) { - $resources[ $url ] = array( + $resources[] = array( 'href' => $url, 'as' => $as, ); @@ -2896,17 +2896,21 @@ function wp_prefetch_admin_assets(): void { * 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. - * Returning an empty array turns the prefetching off. + * 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. * * @since 7.2.0 * * @param array $resources { - * Resources to prefetch, keyed by URL. + * Array of resources and their attributes to prefetch. * * @type array ...$0 { - * @type string $href URL to prefetch. - * @type string $as How the browser should treat the resource. + * Array of resource attributes. + * + * @type string $href URL to prefetch. Required. + * @type string $as How the browser should treat the resource (`script`, `style`). + * Required. * } * } * @param string $next_screen URL of the screen the assets are being prefetched for. Always @@ -2923,6 +2927,9 @@ function wp_prefetch_admin_assets(): void { return; } + $unique_resources = array(); + + // Parse the complete resource list and extract unique resources. foreach ( $resources as $resource ) { if ( ! is_array( $resource ) ) { continue; @@ -2935,6 +2942,15 @@ function wp_prefetch_admin_assets(): void { 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 ), From c6c18bb989ca3e395f51d704084077766656ebfc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 18 Aug 2026 17:15:21 -0700 Subject: [PATCH 11/35] Stop implying the prefetch filter only takes scripts and styles Those are the two destinations core itself prefetches, but nothing in the code constrains the attribute, and a callback with a reason to prefetch an image, a font or a document should not read the documentation as ruling it out. Describing the values the way `wp_preload_resources()` already does keeps the two filters saying the same thing about the same attribute. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 8ebd7542ca1b7..0d485775068f9 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2909,8 +2909,8 @@ function wp_prefetch_admin_assets(): void { * Array of resource attributes. * * @type string $href URL to prefetch. Required. - * @type string $as How the browser should treat the resource (`script`, `style`). - * 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 From a12640fb49bc320d8f3e4d0f47409b5470a2f7f6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 18 Aug 2026 17:20:51 -0700 Subject: [PATCH 12/35] Carry each registry alongside the handles it registered Deriving the registry from the destination meant a ternary re-answering, once per group, a question the loop had just asked. Pairing the two in the array being walked lets the destination stay what it is for, which is the value of the `as` attribute. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 0d485775068f9..6c2e6b5f9760d 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2864,12 +2864,13 @@ function wp_prefetch_admin_assets(): void { $resources = array(); - foreach ( array( - 'script' => $script_handles, - 'style' => $style_handles, - ) as $as => $handles ) { - $dependencies = 'script' === $as ? wp_scripts() : wp_styles(); - + foreach ( + array( + 'script' => array( wp_scripts(), $script_handles ), + 'style' => array( wp_styles(), $style_handles ), + ) + as $as => list( $dependencies, $handles ) + ) { foreach ( $handles as $handle ) { /* * The login screen shares a handful of these handles and has already printed them by From cec3373c767fc5ce8a769f85e7344dc27731530c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 18 Aug 2026 17:24:07 -0700 Subject: [PATCH 13/35] Stop naming the login screen where any screen is meant Two passages were written while the login screen was the only place this ran, and kept naming it after the Dashboard and the post list tables started printing these links too: the note on skipping handles already printed, and the explanation of why these are prefetches rather than preloads. Both describe how the function behaves wherever it runs, so both now say so. The remaining mentions are left alone, being the ones that are about the login screen: its own bullet in the list of contexts, the admin-wide handles not varying with where a login lands, the flows that print nothing, and everything inside the branch that only runs on `login_head`. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 6c2e6b5f9760d..6b5a1d660b49f 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2684,12 +2684,12 @@ function _wp_prefetch_target_is_block_editor( string $url ): bool { * 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 login screen itself, which is + * 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 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`. + * that 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. @@ -2873,9 +2873,9 @@ function wp_prefetch_admin_assets(): void { ) { 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. + * 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; From a6ab6ba2c7d8162b1870aebe87f103ffb1aa80fd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 18 Aug 2026 17:31:57 -0700 Subject: [PATCH 14/35] Inline the block editor check into its only caller A helper named after the caller's concern rather than its own work is a helper that exists only to shorten a call site, and this one had exactly one. Folding it in costs eight lines in a function that reads no worse for them, and spares the global namespace a permanent addition. The two remaining helpers stay: resolving a handle to the URLs it loads from, and expanding handles to include their dependencies, are both described without reference to prefetching and would serve any caller that wanted them. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 40 ++++++++++--------------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 6b5a1d660b49f..ca726b9713ac8 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2638,33 +2638,6 @@ function _wp_expand_dependency_handles( WP_Dependencies $dependencies, array $ha return array_keys( $expanded ); } -/** - * Determines whether a URL points at the block editor. - * - * @since 7.2.0 - * @access private - * - * @param string $url URL to examine. - * @return bool Whether loading the URL would open the block editor. - */ -function _wp_prefetch_target_is_block_editor( string $url ): bool { - $file = basename( (string) wp_parse_url( $url, PHP_URL_PATH ) ); - - if ( 'post-new.php' === $file ) { - return true; - } - - if ( 'post.php' !== $file ) { - return false; - } - - // post.php also handles trashing, restoring and bulk edits, none of which loads the editor. - $query = array(); - wp_parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query ); - - return isset( $query['action'] ) && 'edit' === $query['action']; -} - /** * Prints prefetch links for the assets of the screen the user is most likely to open next. * @@ -2837,7 +2810,18 @@ function wp_prefetch_admin_assets(): void { ); } - if ( _wp_prefetch_target_is_block_editor( $next_screen ) ) { + /* + * 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 rather than the full set: everything these depend on is pulled in with them, so the * list follows the dependencies declared in wp_default_styles() instead of restating them. From 7a646ea55f6d4c7675d7fb8f77d1b57972772514 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 18 Aug 2026 17:34:17 -0700 Subject: [PATCH 15/35] Remove default priority from add_action() --- src/wp-includes/default-filters.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 3f132608fa2be..a8def38d644e0 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -393,8 +393,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', 10 ); -add_action( 'admin_head', 'wp_prefetch_admin_assets', 10 ); +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 ); From 8fea842bbb5bb741c3a4a103a2e019c8b40d1e25 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 18 Aug 2026 17:42:11 -0700 Subject: [PATCH 16/35] Say why the URL resolver names two registries and not their parent Typing the parameter as WP_Dependencies passes static analysis, which is the trap: with only WP_Scripts and WP_Styles in view, ruling out the one leaves the other, so every member the URL is built from resolves. Gutenberg's WP_Fonts is a third subclass and declares none of them, and a caller reaching this with one would land on an undefined property several lines into the function rather than a type error at its door. Since the analyser cannot make that argument, the docblock does. The handle is also documented as non-empty, matching the URLs already promised of the return. Co-Authored-By: Claude Opus 5 --- src/wp-includes/script-loader.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index ca726b9713ac8..e39ea8b76ba95 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2504,6 +2504,11 @@ function script_concat_settings() { * 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. * + * The registry is deliberately not typed as WP_Dependencies, whose subclasses need not declare the + * `base_url`, `content_url`, `default_version`, `text_direction` and `_css_href()` members the URL + * is built from. Static analysis does not object to the wider type, having no reason to expect a + * subclass beyond the two named here. + * * @since 7.2.0 * @access private * @@ -2512,6 +2517,7 @@ function script_concat_settings() { * @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-param non-empty-string $handle * @phpstan-return list */ function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { From 274eb55f8ecc2e980876dee632eb739a0a21fb98 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 25 Sep 2026 22:13:28 -0700 Subject: [PATCH 17/35] Stop claiming prefetch reuse is independent of cache headers The docblock argued for prefetch over preload partly on the grounds that preload would leave reuse across the navigation dependent on the static files' HTTP cache headers. Prefetch is no different. With the login screen loaded in a fresh browser context under Fast 4G, a login a few seconds later found all 24 prefetched files in the cache without a request, while a login four minutes later revalidated 18 of them with a 304. Those 18 were exactly the files whose heuristic freshness, a tenth of the time since `Last-Modified`, had run out; the six recent enough to stay fresh were still served from the cache. No five-minute exemption for unused prefetches applied. The case for prefetch still stands on priority and on preload's unused-resource warnings, so the paragraph now rests on those, and a new one says what reuse actually depends on: whatever freshness the server's headers, or their absence, give the file. A stale response still saves the download, just not the round trip. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 8a2b0ae2c194f..5aac7198e5c66 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2666,10 +2666,15 @@ function _wp_expand_dependency_handles( WP_Dependencies $dependencies, array $ha * * 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 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 - * that screen's own render-blocking assets without needing `fetchpriority`. + * 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. From 98185207328a6511a27ab716ef8fcc58c8bc2ec3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 00:24:34 -0700 Subject: [PATCH 18/35] Prefetch only what blocks the admin's first paint from the login screen The admin-wide list was taken from what load-scripts.php and load-styles.php bundle, but being concatenated was only ever a stand-in for what matters, which is holding back the first paint. With concatenation off, `hoverIntent`, `wp-dom-ready` and `wp-hooks` are printed in the footer, and Chrome marks only the stylesheets and the three head scripts as render-blocking on the Dashboard. Measured login-to-Dashboard on Fast 4G, ten runs each, dropping the three made no difference to LCP (894 ms against 900 ms median) or anything else. The footer's scripts were benchmarked as a set too, since they do block DOMContentLoaded. When their prefetch finishes before the login is submitted the gain is large: DOMContentLoaded 3.6 s to 1.05 s on Fast 4G, LCP unchanged. But the set is about 1.2 MB compressed, mostly the command palette's dependencies, and on Fast 3G it had not finished 3 s or even 8 s after the login screen loaded. What was still in flight kept downloading across the navigation and competed with the Dashboard's render-blocking stylesheets, costing 574 ms and 1,038 ms of LCP respectively. Plain prefetch links cannot tell those cases apart, so the footer stays out, and the comment says why. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 5aac7198e5c66..88b233a3aca16 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2653,9 +2653,9 @@ function _wp_expand_dependency_handles( WP_Dependencies $dependencies, array $ha * * - 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 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. + * 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 @@ -2779,19 +2779,25 @@ function wp_prefetch_admin_assets(): void { if ( $on_login ) { /* - * 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. + * 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 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` + * 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. + * * 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( From 45e7e8a3cc1317112e4a75f118119afd8b06a165 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 10:22:05 -0700 Subject: [PATCH 19/35] Allow far-future caching of static assets in the local environment Add an nginx `expires` directive for CSS, JavaScript, font and image files to the config template used by the local Docker environment, controlled by a new `LOCAL_NGINX_STATIC_EXPIRES` option. It defaults to `off`, which leaves the headers exactly as they are; setting it to a duration such as `1y` sends `Cache-Control: max-age` and `Expires` the way a typical production server does. Without it, the local environment sends only `Last-Modified` and `ETag` for static files, so browsers fall back to a heuristic lifetime of a tenth of each file's age. That makes caching behavior depend on how recently the files were built, which no benchmark records, and turns into a revalidation round trip any reuse that production would serve straight from the cache. It also treats the two sides of a concatenation comparison differently: `load-scripts.php` and `load-styles.php` already send a one-year `max-age` of their own, while the individual files they replace get none. A missing file still falls through to WordPress, as it does under `location /`, so a virtual asset such as the dynamic `/favicon.ico` keeps working. The option is opt-in because most core assets are versioned by the WordPress version rather than by their content, so with a far-future lifetime an edited file is served stale until a hard reload. Since this changes `docker-compose.yml` in addition to the nginx template, existing checkouts need to recreate the web server container to pick up the change. Co-Authored-By: Claude Opus 5.5 --- .env.example | 5 +++++ docker-compose.yml | 3 ++- tools/local-env/default.template | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index dddbb4be4c260..1ca8f9658dbef 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,11 @@ LOCAL_DIR=src # Valid options are 'on' and 'off'. LOCAL_NGINX_COMPRESSION=off +# How long browsers may cache static assets served by the web server, as an nginx `expires` value. +# Valid options are 'off' and a duration such as '1y'. A far-future duration matches typical +# production behavior, but edited CSS and JavaScript is then served stale until a hard reload. +LOCAL_NGINX_STATIC_EXPIRES=off + # The PHP version to use. Valid options are 'latest', and '{version}-fpm'. LOCAL_PHP=latest diff --git a/docker-compose.yml b/docker-compose.yml index 06a2d2a40cec7..3d80839073aa0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,13 +15,14 @@ services: environment: LOCAL_DIR: ${LOCAL_DIR-src} LOCAL_NGINX_COMPRESSION: ${LOCAL_NGINX_COMPRESSION:-off} + LOCAL_NGINX_STATIC_EXPIRES: ${LOCAL_NGINX_STATIC_EXPIRES:-off} volumes: - ./tools/local-env/default.template:/etc/nginx/conf.d/default.template - ./:/var/www # Load our config file, substituting environment variables into the config. - command: /bin/sh -c "envsubst '$$LOCAL_DIR $$LOCAL_NGINX_COMPRESSION' < /etc/nginx/conf.d/default.template > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'" + command: /bin/sh -c "envsubst '$$LOCAL_DIR $$LOCAL_NGINX_COMPRESSION $$LOCAL_NGINX_STATIC_EXPIRES' < /etc/nginx/conf.d/default.template > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'" depends_on: php: diff --git a/tools/local-env/default.template b/tools/local-env/default.template index 4d8dec3c131fc..2cd95b9447df8 100644 --- a/tools/local-env/default.template +++ b/tools/local-env/default.template @@ -40,6 +40,11 @@ server { try_files $uri $uri/ /index.php?$args; } + location ~* \.(?:css|js|mjs|svg|woff2?|ttf|otf|eot|png|jpe?g|gif|webp|avif|ico)$ { + expires ${LOCAL_NGINX_STATIC_EXPIRES}; + try_files $uri /index.php?$args; + } + location ~ \.php$ { try_files $uri =404; fastcgi_split_path_info ^(.+\.php)(/.+)$; From 81f72fd5c7018ce0159970a3349f067180e005c7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 21:48:54 -0700 Subject: [PATCH 20/35] Derive the login prefetch set from root handles List the login screen's prefetched assets as a few roots, as the editor set already is, instead of restating the 24 stylesheets and 3 scripts they expand to. `wp-admin` stands for the admin's own stylesheets and `jquery` for `jquery-core` and `jquery-migrate`, so the set now follows the dependencies declared in `wp_default_styles()` and `wp_default_scripts()` without needing to be kept in sync by hand. Both sets of roots are expanded once, inline, so the single-use `_wp_expand_dependency_handles()` helper is removed. The printed prefetch links are unchanged on the login screen, on a login redirecting to `post-new.php`, and on the Dashboard. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 149 ++++++++++++------------------ 1 file changed, 57 insertions(+), 92 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 88b233a3aca16..9016250b6fc98 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2605,46 +2605,6 @@ function _wp_resolve_dependency_urls( $dependencies, string $handle ): array { return array( $src ); } -/** - * Expands a set of handles to include everything they depend on. - * - * Lets a caller name a few roots instead of restating a dependency tree that is already declared - * at registration, so the set keeps up with changes to those declarations on its own. - * - * @since 7.2.0 - * @access private - * - * @param WP_Dependencies $dependencies Registry to resolve the handles against. - * @param string[] $handles Root handles to expand. - * @return string[] The roots together with everything they depend on, roots first. Handles that - * are not registered are dropped, as are their dependencies. - * - * @phpstan-param non-empty-list $handles - * @phpstan-return list - */ -function _wp_expand_dependency_handles( WP_Dependencies $dependencies, array $handles ): array { - $expanded = array(); - $queue = array_values( $handles ); - - while ( $queue ) { - $handle = array_shift( $queue ); - - if ( isset( $expanded[ $handle ] ) || ! isset( $dependencies->registered[ $handle ] ) ) { - continue; - } - - $expanded[ $handle ] = true; - - foreach ( $dependencies->registered[ $handle ]->deps as $dependency ) { - if ( is_string( $dependency ) && '' !== $dependency && ! isset( $expanded[ $dependency ] ) ) { - $queue[] = $dependency; - } - } - } - - return array_keys( $expanded ); -} - /** * Prints prefetch links for the assets of the screen the user is most likely to open next. * @@ -2706,9 +2666,9 @@ function wp_prefetch_admin_assets(): void { return; } - $on_login = ( 'login_head' === current_action() ); - $script_handles = array(); - $style_handles = array(); + $on_login = ( 'login_head' === current_action() ); + $script_roots = array(); + $style_roots = array(); if ( $on_login ) { /* @@ -2781,8 +2741,8 @@ function wp_prefetch_admin_assets(): void { /* * 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 listed here loads on all - * admin screens, not just the one the login happens to land on, so the list does not depend + * 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. * @@ -2792,38 +2752,25 @@ function wp_prefetch_admin_assets(): void { * 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. * - * Handles registered without a source of their own, or not registered at all, are skipped. + * 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. */ - $script_handles = array( - 'jquery-core', - 'jquery-migrate', + $script_roots = array( + 'jquery', 'utils', ); - $style_handles = array( - 'dashicons', + $style_roots = array( + 'wp-admin', + 'buttons', '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', ); } @@ -2841,25 +2788,21 @@ function wp_prefetch_admin_assets(): void { if ( $next_screen_is_block_editor ) { /* - * Roots rather than the full set: everything these depend on is pulled in with them, so the - * list follows the dependencies declared in wp_default_styles() instead of restating them. - * `wp-edit-post` alone accounts for most of the editor chrome; the rest cover the media - * modal, the block directory, the format library and the editor's own reset. + * Roots as well, expanded along with any from the login screen. `wp-edit-post` alone accounts + * for most of the editor chrome; the rest cover the media modal, the block directory, the + * format library and the editor's own reset. */ - $style_handles = array_merge( - $style_handles, - _wp_expand_dependency_handles( - wp_styles(), - array( - 'wp-edit-post', - 'wp-block-editor-content', - 'wp-block-directory', - 'wp-format-library', - 'wp-reset-editor-styles', - 'editor-buttons', - 'media-views', - 'imgareaselect', - ) + $style_roots = array_merge( + $style_roots, + array( + 'wp-edit-post', + 'wp-block-editor-content', + 'wp-block-directory', + 'wp-format-library', + 'wp-reset-editor-styles', + 'editor-buttons', + 'media-views', + 'imgareaselect', ) ); } @@ -2868,12 +2811,34 @@ function wp_prefetch_admin_assets(): void { foreach ( array( - 'script' => array( wp_scripts(), $script_handles ), - 'style' => array( wp_styles(), $style_handles ), + 'script' => array( wp_scripts(), $script_roots ), + 'style' => array( wp_styles(), $style_roots ), ) - as $as => list( $dependencies, $handles ) + as $as => list( $dependencies, $queue ) ) { - foreach ( $handles as $handle ) { + /* + * 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. From 8a36ca8f254c453d3d6ac163e2e4a0cb0b5e2f79 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 21:55:17 -0700 Subject: [PATCH 21/35] Share URL building between do_item() and the admin prefetch Move the construction of a script's URL out of `WP_Scripts::do_item()` into a new `WP_Scripts::get_src()`, and the construction of a style's right-to-left URL out of `WP_Styles::do_item()` into a new `WP_Styles::get_rtl_href()`. `do_item()` calls them in place of the inline code, and `wp_prefetch_admin_assets()` calls them together with the existing `WP_Styles::_css_href()`. This replaces `_wp_resolve_dependency_urls()`, which copied that code and had already fallen behind it: the copy did not append the handle's query arguments or keep a URL fragment after the version. `get_src()` follows `WP_Script_Modules::get_src()`. `get_rtl_href()` returns null when no right-to-left stylesheet applies, so `do_item()` still prints one exactly when it did before. It narrows the `suffix` and `rtl` data to strings, which the moved code assumed without checking. The URLs are unchanged for every registered script and style, in both text directions, and so are the printed prefetch links on the login screen and in the admin. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/class-wp-scripts.php | 95 ++++++++++++------- src/wp-includes/class-wp-styles.php | 57 +++++++++-- src/wp-includes/script-loader.php | 135 ++++++--------------------- 3 files changed, 139 insertions(+), 148 deletions(-) diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index 36a0487bede56..c7d26e799c828 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 = $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/script-loader.php b/src/wp-includes/script-loader.php index 9016250b6fc98..02b65dc29be0c 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2498,113 +2498,6 @@ 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. - * - * The registry is deliberately not typed as WP_Dependencies, whose subclasses need not declare the - * `base_url`, `content_url`, `default_version`, `text_direction` and `_css_href()` members the URL - * is built from. Static analysis does not object to the wider type, having no reason to expect a - * subclass beyond the two named here. - * - * @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-param non-empty-string $handle - * @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 for the assets of the screen the user is most likely to open next. * @@ -2848,7 +2741,33 @@ function wp_prefetch_admin_assets(): void { continue; } - foreach ( _wp_resolve_dependency_urls( $dependencies, $handle ) as $url ) { + /* + * 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; + } + } + } else { + $urls = array( $dependencies->get_src( $handle ) ); + } + + foreach ( array_filter( $urls ) as $url ) { $resources[] = array( 'href' => $url, 'as' => $as, From 176c7e4cb480d749e2a97f9b0184912c7cc122df Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 22:07:52 -0700 Subject: [PATCH 22/35] Add tests for WP_Scripts::get_src() and WP_Styles::get_rtl_href() Cover how each method builds its URL: the base URL, content URL and version handling, query strings, fragments and handle arguments for scripts, and each form of `rtl` data and the `suffix` for styles. Also cover the cases that return nothing, the `script_loader_src` and `style_loader_src` filters, and that each URL matches the one `do_item()` prints. Co-Authored-By: Claude Opus 5.5 --- .../tests/dependencies/wpScripts/getSrc.php | 177 ++++++++++++++++++ .../dependencies/wpStyles/getRtlHref.php | 177 ++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 tests/phpunit/tests/dependencies/wpScripts/getSrc.php create mode 100644 tests/phpunit/tests/dependencies/wpStyles/getRtlHref.php 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 ), + ); + } +} From a01bb6b9a8044ec90a1aaa0181de1a299e393261 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 22:10:13 -0700 Subject: [PATCH 23/35] Note where the login prefetch roots are enqueued The login screen prefetches the dependencies of a handful of root handles, each of which mirrors an enqueue elsewhere in the admin. Add a comment at each of those enqueues saying that `wp_prefetch_admin_assets()` needs updating if it changes, and list the enqueue sites in the comment above the roots, so that the two are kept in sync from either side. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/admin-header.php | 4 ++++ src/wp-admin/admin.php | 4 ++++ src/wp-admin/includes/class-wp-internal-pointers.php | 5 ++++- src/wp-includes/class-wp-admin-bar.php | 1 + src/wp-includes/functions.php | 1 + src/wp-includes/script-loader.php | 7 +++++++ 6 files changed, 21 insertions(+), 1 deletion(-) 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 ); ?> Date: Sat, 26 Sep 2026 22:13:08 -0700 Subject: [PATCH 24/35] Drop the redundant wp-pointer prefetch root The `wp-pointer` stylesheet is already a dependency of `widgets`, which `wp-admin` depends on, so expanding the `wp-admin` root prefetches it on its own. Listing it as a root also pointed at `WP_Internal_Pointers::enqueue_scripts()` as the enqueue to keep in sync with, which only enqueues it while the user has a pointer left to dismiss. Remove the root and the note there. The prefetched set is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/includes/class-wp-internal-pointers.php | 5 +---- src/wp-includes/script-loader.php | 6 ++---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/wp-admin/includes/class-wp-internal-pointers.php b/src/wp-admin/includes/class-wp-internal-pointers.php index 4de965aeccdc9..22233884b4beb 100644 --- a/src/wp-admin/includes/class-wp-internal-pointers.php +++ b/src/wp-admin/includes/class-wp-internal-pointers.php @@ -98,10 +98,7 @@ public static function enqueue_scripts( $hook_suffix ) { return; } - /* - * Add pointers script and style to queue. The style is prefetched from the login screen by - * wp_prefetch_admin_assets(), which needs updating if this changes. - */ + // Add pointers script and style to queue. wp_enqueue_style( 'wp-pointer' ); wp_enqueue_script( 'wp-pointer' ); } diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index fe6cf430f41f1..781659c9ebbd2 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2655,9 +2655,8 @@ function wp_prefetch_admin_assets(): void { * * 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-pointer` in - * WP_Internal_Pointers::enqueue_scripts(), `wp-auth-check` in wp_auth_check_load(), and - * `wp-commands` in wp_enqueue_command_palette_assets(). + * 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', @@ -2668,7 +2667,6 @@ function wp_prefetch_admin_assets(): void { 'wp-admin', 'buttons', 'admin-bar', - 'wp-pointer', 'wp-auth-check', 'wp-commands', ); From debcf11e8435c97ce812159db2122ee70370f8d2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 22:14:40 -0700 Subject: [PATCH 25/35] Drop the redundant editor prefetch roots `wp-edit-post` depends on `wp-edit-blocks`, which depends on both `wp-block-editor-content` and `wp-reset-editor-styles`, so expanding `wp-edit-post` already prefetches them. Remove them as roots so there are fewer enqueues to keep in sync with. The prefetched set is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 781659c9ebbd2..e030daa350e1e 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2686,17 +2686,16 @@ function wp_prefetch_admin_assets(): void { 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; the rest cover the media modal, the block directory, the - * format library and the editor's own reset. + * 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. */ $style_roots = array_merge( $style_roots, array( 'wp-edit-post', - 'wp-block-editor-content', 'wp-block-directory', 'wp-format-library', - 'wp-reset-editor-styles', 'editor-buttons', 'media-views', 'imgareaselect', From 68844a5540b8085abb739ad40da4a5474bfaa501 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 22:16:27 -0700 Subject: [PATCH 26/35] Note where the editor prefetch roots are enqueued As for the login screen's roots, add a comment at each enqueue that the editor's prefetch roots mirror, saying that `wp_prefetch_admin_assets()` needs updating if it changes, and list those enqueues in the comment above the roots. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/edit-form-blocks.php | 1 + src/wp-includes/class-wp-editor.php | 5 ++++- src/wp-includes/media.php | 2 ++ src/wp-includes/script-loader.php | 9 +++++++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/edit-form-blocks.php b/src/wp-admin/edit-form-blocks.php index a6d198ff15343..693919d3d5bf1 100644 --- a/src/wp-admin/edit-form-blocks.php +++ b/src/wp-admin/edit-form-blocks.php @@ -342,6 +342,7 @@ static function ( $classes ) { /** * Styles */ +// Prefetched for the block editor by wp_prefetch_admin_assets(), which needs updating if this changes. wp_enqueue_style( 'wp-edit-post' ); /** 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/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 e030daa350e1e..26bc9085fd51a 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2689,6 +2689,13 @@ function wp_prefetch_admin_assets(): void { * 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, @@ -3267,6 +3274,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' ); } @@ -3277,6 +3285,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' ); } From 6893636f0a238f744bc04d60d675ca4b5e7fd9bc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 22:38:54 -0700 Subject: [PATCH 27/35] Add tests for wp_prefetch_admin_assets() Cover what the login screen prefetches and when, depending on the login action and `redirect_to`; what the Dashboard and post list tables prefetch for the editor, and for which users and post types; skipping handles already printed; right-to-left stylesheets; and how the `prefetch_admin_assets` filter's result is deduplicated and sanitized. Prefetching depends on the `CONCATENATE_SCRIPTS` constant, so each test defines it in a separate process. A todo notes that this goes away once concatenation is retired. Co-Authored-By: Claude Opus 5.5 --- .../dependencies/wpPrefetchAdminAssets.php | 512 ++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php new file mode 100644 index 0000000000000..8bc2c7dba782e --- /dev/null +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -0,0 +1,512 @@ +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 ), + 'absolute editor URL' => array( 'http://example.org/wp-admin/post-new.php', true ), + ); + } + + /** + * 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. + */ + public function test_login_prints_nothing_when_not_leading_to_admin( array $request ): void { + define( 'CONCATENATE_SCRIPTS', false ); + + $this->assertSame( array(), $this->get_prefetched_on_login( $request ) ); + } + + /** + * Data provider for {@see self::test_login_prints_nothing_when_not_leading_to_admin()}. + * + * @return array }> + */ + public function data_login_requests_not_leading_to_admin(): array { + return array( + 'lost password' => array( array( 'action' => 'lostpassword' ) ), + 'registration' => array( array( 'action' => 'register' ) ), + 'logout' => array( array( 'action' => 'logout' ) ), + 'interim login' => array( array( 'interim-login' => '1' ) ), + 'front end redirect' => array( array( 'redirect_to' => '/hello-world/' ) ), + 'absolute front end' => array( array( 'redirect_to' => 'http://example.org/hello-world/' ) ), + 'lookalike admin path' => array( array( 'redirect_to' => '/wp-admin-lookalike/' ) ), + ); + } + + /** + * 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://attacker.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 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 $next_screen Expected URL of the editor being prefetched for. + */ + public function test_admin_screen_prefetches_editor_assets( string $screen, string $next_screen ): 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( $next_screen, $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', 'http://example.org/wp-admin/post-new.php?post_type=post' ), + 'Posts list' => array( 'edit', 'http://example.org/wp-admin/post-new.php?post_type=post' ), + 'Pages list' => array( 'edit-page', 'http://example.org/wp-admin/post-new.php?post_type=page' ), + ); + } + + /** + * Tests that admin screens other than the Dashboard and the post list tables prefetch nothing. + * + * @ticket 57548 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_other_admin_screen_prints_nothing(): 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( 'plugins' ) ); + } + + /** + * 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 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. + * @return list Prefetch links in the order printed. + */ + private function get_prefetched_on_login( array $request = array() ): array { + $_REQUEST = $request; + + 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}." + ); + } +} From 35b06f6479c42caa164f7c269457da5d6313b6a9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 23:44:44 -0700 Subject: [PATCH 28/35] Enhance comment on resource attributes in script-loader Expanded comment to clarify resource handling and filtering. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/wp-includes/script-loader.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 26bc9085fd51a..c824ad33d8150 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2793,9 +2793,11 @@ function wp_prefetch_admin_assets(): void { * 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. + * 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. This filter also runs on the login screen in a logged-out, + * non-admin request, while the `script_loader_src` and `style_loader_src` filters build URLs. + * Returning an empty array turns the prefetching off. * * @since 7.2.0 * From 4524a2dcf72435bf04c62e83417d0b82d710fabf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 23:48:48 -0700 Subject: [PATCH 29/35] Fix Copilot's broken indent in 35b06f6479c42caa164f7c269457da5d6313b6a9 --- src/wp-includes/script-loader.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index c824ad33d8150..581d10f8fdf3c 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2793,11 +2793,11 @@ function wp_prefetch_admin_assets(): void { * 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. This filter also runs on the login screen in a logged-out, - * non-admin request, while the `script_loader_src` and `style_loader_src` filters build URLs. - * Returning an empty array turns the prefetching off. + * 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. This filter also runs on the login screen in a logged-out, + * non-admin request, while the `script_loader_src` and `style_loader_src` filters build URLs. + * Returning an empty array turns the prefetching off. * * @since 7.2.0 * From 6368d7ae5a06c66c173413b6511673c5df5172ab Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 26 Sep 2026 23:50:29 -0700 Subject: [PATCH 30/35] Clarify when the prefetch URLs may not match the admin's Restore the paragraph on the `prefetch_admin_assets` filter's output to its original indentation and wording, and explain the login screen's execution context in a paragraph of its own: the URLs are built before the user is authenticated and outside the admin, so a `script_loader_src` or `style_loader_src` callback depending on either can produce a URL the admin screen will not request. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 581d10f8fdf3c..ac17edc3f30ea 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2793,11 +2793,14 @@ function wp_prefetch_admin_assets(): void { * 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. This filter also runs on the login screen in a logged-out, - * non-admin request, while the `script_loader_src` and `style_loader_src` filters build URLs. - * Returning an empty array turns the prefetching off. + * 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 * From 2b1140144e2be32966ceae32b9db7f99a8be0c8f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 27 Sep 2026 08:41:12 -0700 Subject: [PATCH 31/35] Use the login action wp-login.php resolved Decide whether the login screen leads to the admin from the `$action` and `$interim_login` globals that wp-login.php has already resolved, as `login_header()` does, instead of re-deriving them from the request. wp-login.php overrides the `action` parameter: a `key` switches to the password reset form and `checkemail` to the check-your-email message, and an action it does not recognize falls back to the login form. So those two screens were prefetching the admin's assets without leading to it, while an unrecognized action showed the login form without prefetching. Nothing is printed either when a plugin fires `login_head` outside wp-login.php, where the globals are not set. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 18 +++-- .../dependencies/wpPrefetchAdminAssets.php | 65 +++++++++++++++---- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index ac17edc3f30ea..dfcfcd74dea5e 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2534,8 +2534,8 @@ function script_concat_settings() { * * 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 and logout flows, on - * an interim login, or when `redirect_to` points outside the admin. + * 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 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. @@ -2543,6 +2543,10 @@ function script_concat_settings() { * @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 { /* @@ -2569,12 +2573,14 @@ function wp_prefetch_admin_assets(): void { * 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. */ - $login_action = isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) - ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) - : 'login'; + global $action, $interim_login; - if ( 'login' !== $login_action || isset( $_REQUEST['interim-login'] ) ) { + if ( 'login' !== $action || $interim_login ) { return; } diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 8bc2c7dba782e..34f2abb808c98 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -149,31 +149,63 @@ public function data_editor_destinations(): array { * * @dataProvider data_login_requests_not_leading_to_admin * - * @param array $request Request parameters of the login screen. + * @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 ): void { + 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 ) ); + $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()}. * - * @return array }> + * 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' ) ), - 'registration' => array( array( 'action' => 'register' ) ), - 'logout' => array( array( 'action' => 'logout' ) ), - 'interim login' => array( array( 'interim-login' => '1' ) ), - 'front end redirect' => array( array( 'redirect_to' => '/hello-world/' ) ), - 'absolute front end' => array( array( 'redirect_to' => 'http://example.org/hello-world/' ) ), - 'lookalike admin path' => array( array( 'redirect_to' => '/wp-admin-lookalike/' ) ), + '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 ), + 'absolute front end' => array( array( 'redirect_to' => 'http://example.org/hello-world/' ), 'login', false ), + 'lookalike admin path' => array( array( 'redirect_to' => '/wp-admin-lookalike/' ), 'login', false ), ); } + /** + * 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. * @@ -413,12 +445,19 @@ public function data_filter_turning_off(): array { /** * Runs the login screen's prefetching and returns the links it printed. * - * @param array $request Request parameters of the login screen. + * @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() ): array { + 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' ); From 4e143375d075ce47e6d4b72b6af39bc89df9349b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 27 Sep 2026 08:49:31 -0700 Subject: [PATCH 32/35] Skip the login prefetch when redirecting to another host The login screen compared only the path of `redirect_to` with the admin's. But `wp_validate_redirect()` accepts any host in `allowed_redirect_hosts`, such as another site on a multisite network, so a login redirecting to that site's admin prefetched this site's assets, which the other admin would request from its own host instead. Also require an absolute `redirect_to` to match the admin's host and port. A relative one stays on this host. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 21 +++++++- .../dependencies/wpPrefetchAdminAssets.php | 52 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index dfcfcd74dea5e..4b2d85fb39503 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2535,7 +2535,8 @@ function script_concat_settings() { * 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 the admin. + * 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. @@ -2604,6 +2605,24 @@ function wp_prefetch_admin_assets(): void { 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 diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 34f2abb808c98..2a7788a2e9acb 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -227,6 +227,58 @@ public function test_login_off_site_redirect_falls_back_to_admin(): void { $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[] = 'other.example.org'; + return $hosts; + } + ); + + $this->assertSame( + array(), + $this->get_prefetched_on_login( array( 'redirect_to' => 'http://other.example.org/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. * From 30703dbda83d346e2b96d5b5aa7de1b7a984770f Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 27 Sep 2026 08:58:45 -0700 Subject: [PATCH 33/35] Derive the prefetch tests' URLs from the site's own The tests hardcoded `http://example.org` for an absolute `redirect_to` pointing at the editor and at the front end, and for the editor URL the admin screens are expected to pass to the filter. Build them with `admin_url()` and `home_url()` instead, so the tests hold on a test site configured with another host. Since data providers run before those can be relied on, the two absolute `redirect_to` cases become tests of their own, and the admin-screen provider supplies the post type rather than the URL. The other allowed host becomes `another.example.net`, so that it cannot coincide with the test site's. Co-Authored-By: Claude Opus 5.5 --- .../dependencies/wpPrefetchAdminAssets.php | 66 ++++++++++++++----- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 2a7788a2e9acb..0de7831d6c490 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -129,16 +129,33 @@ public function test_login_prefetches_editor_assets_for_editor_destination( stri */ 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 ), - 'absolute editor URL' => array( 'http://example.org/wp-admin/post-new.php', true ), + '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. * @@ -184,11 +201,24 @@ public function data_login_requests_not_leading_to_admin(): array { '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 ), - 'absolute front end' => array( array( 'redirect_to' => 'http://example.org/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. @@ -220,7 +250,7 @@ public function test_login_off_site_redirect_falls_back_to_admin(): void { $filter = new MockAction(); add_filter( 'prefetch_admin_assets', array( $filter, 'filter' ), 10, 2 ); - $links = $this->get_prefetched_on_login( array( 'redirect_to' => 'https://attacker.example.com/wp-admin/post-new.php' ) ); + $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/#' ); @@ -242,14 +272,14 @@ public function test_login_prints_nothing_for_admin_on_another_allowed_host(): v add_filter( 'allowed_redirect_hosts', static function ( array $hosts ): array { - $hosts[] = 'other.example.org'; + $hosts[] = 'another.example.net'; return $hosts; } ); $this->assertSame( array(), - $this->get_prefetched_on_login( array( 'redirect_to' => 'http://other.example.org/wp-admin/post-new.php' ) ) + $this->get_prefetched_on_login( array( 'redirect_to' => 'http://another.example.net/wp-admin/post-new.php' ) ) ); } @@ -289,10 +319,10 @@ public function test_login_prints_nothing_for_admin_on_another_port(): void { * * @dataProvider data_admin_screens_leading_to_editor * - * @param string $screen Screen ID. - * @param string $next_screen Expected URL of the editor being prefetched for. + * @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 $next_screen ): void { + 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' ) ) ); @@ -303,7 +333,7 @@ public function test_admin_screen_prefetches_editor_assets( string $screen, stri $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( $next_screen, $filter->get_args()[0][1] ); + $this->assertSame( add_query_arg( 'post_type', $post_type, admin_url( 'post-new.php' ) ), $filter->get_args()[0][1] ); } /** @@ -313,9 +343,9 @@ public function test_admin_screen_prefetches_editor_assets( string $screen, stri */ public function data_admin_screens_leading_to_editor(): array { return array( - 'Dashboard' => array( 'dashboard', 'http://example.org/wp-admin/post-new.php?post_type=post' ), - 'Posts list' => array( 'edit', 'http://example.org/wp-admin/post-new.php?post_type=post' ), - 'Pages list' => array( 'edit-page', 'http://example.org/wp-admin/post-new.php?post_type=page' ), + 'Dashboard' => array( 'dashboard', 'post' ), + 'Posts list' => array( 'edit', 'post' ), + 'Pages list' => array( 'edit-page', 'page' ), ); } From d4f6bf89121f6c8258fda79e8a8fc812917ea0af Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 27 Sep 2026 09:06:23 -0700 Subject: [PATCH 34/35] Test that network and user Dashboards prefetch nothing `WP_Screen` suffixes both the screen ID and the base of the Network Admin and User Admin Dashboards with `-network` and `-user`, so they do not match the `dashboard` base and are already excluded along with every other admin screen. Cover them next to the Plugins screen so the base check cannot be loosened without noticing. Also use plain `string` for the values in two data providers' return types, keeping `non-falsy-string` for the keys. Co-Authored-By: Claude Opus 5.5 --- .../dependencies/wpPrefetchAdminAssets.php | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 0de7831d6c490..81ccbc9d4615e 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -339,7 +339,7 @@ public function test_admin_screen_prefetches_editor_assets( string $screen, stri /** * Data provider for {@see self::test_admin_screen_prefetches_editor_assets()}. * - * @return array + * @return array */ public function data_admin_screens_leading_to_editor(): array { return array( @@ -350,18 +350,35 @@ public function data_admin_screens_leading_to_editor(): array { } /** - * Tests that admin screens other than the Dashboard and the post list tables prefetch nothing. + * 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(): void { + 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( 'plugins' ) ); + $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' ), + ); } /** From d40ea0fd9ca25d6d7309256deaf20fcad6ced958 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 27 Sep 2026 09:15:38 -0700 Subject: [PATCH 35/35] Pass stylesheet URLs to the prefetch filter unescaped `WP_Styles::_css_href()` and `WP_Styles::get_rtl_href()` return URLs escaped for an HTML attribute, with `&` as `&`, while `WP_Scripts::get_src()` returns them unescaped. So the `prefetch_admin_assets` filter received the two in different forms, and a callback appending the plain form of a stylesheet URL with more than one query argument was not collapsed with it, despite what the filter documents. Decode the stylesheet URLs before building the list. The printed links are unchanged, since each URL is escaped again when printed. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 8 +++ .../dependencies/wpPrefetchAdminAssets.php | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 4b2d85fb39503..bee8629509b64 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2798,6 +2798,14 @@ function wp_prefetch_admin_assets(): void { $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 ) ); } diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 81ccbc9d4615e..ce16bb9bdc9af 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -451,6 +451,60 @@ public function test_prefetches_rtl_stylesheets(): void { $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. *