From b3809c846db3d16fa99e915ea9d5c5cebd1d6de0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 16 Aug 2026 14:36:54 -0700 Subject: [PATCH 01/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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/71] 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. * From 73bce0910c0d66363f0777b5232c0079fe76f912 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 28 Sep 2026 12:52:20 -0700 Subject: [PATCH 36/71] Add WP_Styles::get_src() and rename get_rtl_href() to get_rtl_src() `wp_prefetch_admin_assets()` built stylesheet URLs with `WP_Styles::_css_href()` and `WP_Styles::get_rtl_href()`, which return them escaped for an HTML attribute, and then decoded them again so the `prefetch_admin_assets` filter would see plain URLs, as it does for scripts from `WP_Scripts::get_src()`. Move the URL building in `_css_href()`, up to and including the `style_loader_src` filter, into a private `build_src()` method that returns the URL unescaped. `_css_href()` escapes its result as before. The new `WP_Styles::get_src()` mirrors `WP_Scripts::get_src()`, returning a handle's URL through `esc_url_raw()`, and `get_rtl_href()` becomes `get_rtl_src()`, returning the right-to-left URL the same way. `WP_Styles::do_item()` escapes that URL when printing it. `build_src()` treats a filter result that is not a string as an empty string, as `WP_Scripts::get_src()` does. Before, `_css_href()` passed such a result to `esc_url()`, which printed `true` or a number as an `href` of `1` or the number, raised a deprecation notice for `null`, and failed with a `TypeError` for an array or object. `wp_prefetch_admin_assets()` now gets every URL from `get_src()`, plus `get_rtl_src()` for stylesheets, with no decoding step. The tags `do_item()` prints are unchanged for every registered style in both text directions. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/class-wp-styles.php | 67 +++++- src/wp-includes/script-loader.php | 33 +-- .../{getRtlHref.php => getRtlSrc.php} | 34 ++- .../tests/dependencies/wpStyles/getSrc.php | 205 ++++++++++++++++++ 4 files changed, 296 insertions(+), 43 deletions(-) rename tests/phpunit/tests/dependencies/wpStyles/{getRtlHref.php => getRtlSrc.php} (83%) create mode 100644 tests/phpunit/tests/dependencies/wpStyles/getSrc.php diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index 643478ff4dd29..91cab3594788e 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -224,10 +224,11 @@ public function do_item( $handle, $group = false ) { */ $tag = apply_filters( 'style_loader_tag', $tag, $handle, $href, $media ); - $rtl_href = $this->get_rtl_href( $handle ); + $rtl_src = $this->get_rtl_src( $handle ); - if ( null !== $rtl_href ) { - $rtl_tag = sprintf( + if ( null !== $rtl_src ) { + $rtl_href = esc_url( $rtl_src ); + $rtl_tag = sprintf( "\n", $rel, esc_attr( $handle ), @@ -266,6 +267,8 @@ public function do_item( $handle, $group = false ) { * stylesheet, which either replaces its left-to-right one (when the data is `'replace'`) * or loads alongside it. * + * Like {@see WP_Styles::get_src()}, the URL is not escaped for an HTML attribute. + * * @since 7.2.0 * * @param string $handle The style's registered handle. @@ -273,7 +276,7 @@ public function do_item( $handle, $group = false ) { * 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 { + public function get_rtl_src( string $handle ): ?string { if ( 'rtl' !== $this->text_direction || ! isset( $this->registered[ $handle ] ) ) { return null; } @@ -296,7 +299,8 @@ public function get_rtl_href( string $handle ): ?string { 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" ) ); + + return str_replace( "{$suffix}.css", "-rtl{$suffix}.css", esc_url_raw( $this->build_src( $obj->src, $ver, "$handle-rtl" ) ) ); } // Any other value is the URL of the right-to-left stylesheet itself. @@ -304,7 +308,7 @@ public function get_rtl_href( string $handle ): ?string { return null; } - return $this->_css_href( $obj->extra['rtl'], $ver, "$handle-rtl" ); + return esc_url_raw( $this->build_src( $obj->extra['rtl'], $ver, "$handle-rtl" ) ); } /** @@ -444,9 +448,55 @@ public function all_deps( $handles, $recursion = false, $group = false ) { * @param string $src The source of the enqueued style. * @param string|false|null $ver The version of the enqueued style. * @param string $handle The style's registered handle. - * @return string Style's fully-qualified URL. + * @return string Style's fully-qualified URL, escaped for use in an HTML attribute. */ public function _css_href( $src, $ver, $handle ) { + return esc_url( $this->build_src( $src, $ver, (string) $handle ) ); + } + + /** + * Gets the URL a registered style is loaded from. + * + * This is the URL printed in the stylesheet's `href` attribute, including the version query + * argument and any arguments added to the handle, after the {@see 'style_loader_src'} filter. + * Unlike {@see WP_Styles::_css_href()}, it is not escaped for an HTML attribute. + * + * @since 7.2.0 + * + * @param string $handle The style's registered handle. + * @return string Style URL, or an empty string when the style is not registered, has no + * source of its own because it only aliases other styles, or was filtered away. + */ + public function get_src( string $handle ): string { + if ( ! isset( $this->registered[ $handle ] ) ) { + return ''; + } + + $obj = $this->registered[ $handle ]; + + // A handle whose source is `true`, like `colors`, gets its URL from the 'style_loader_src' filter. + if ( ! $obj->src ) { + return ''; + } + + return esc_url_raw( $this->build_src( $obj->src, $obj->ver, $handle ) ); + } + + /** + * Builds a style's fully-qualified URL and passes it through the 'style_loader_src' filter. + * + * The result is not escaped, so callers can escape it for where it is used. + * + * @since 7.2.0 + * + * @param string|true $src The source of the style, or true for one whose URL comes + * from the {@see 'style_loader_src'} filter. + * @param string|false|null $ver The version of the style. + * @param string $handle The style's registered handle. + * @return string The filtered URL, or an empty string when the filter returns anything other + * than a string. + */ + private function build_src( $src, $ver, string $handle ): string { if ( ! is_bool( $src ) && ! preg_match( '|^(https?:)?//|', $src ) && ! ( $this->content_url && str_starts_with( $src, $this->content_url ) ) ) { $src = $this->base_url . $src; } @@ -487,7 +537,8 @@ public function _css_href( $src, $ver, $handle ) { * @param string $handle The style's registered handle. */ $src = apply_filters( 'style_loader_src', $src, $handle ); - return esc_url( $src ); + + return is_string( $src ) ? $src : ''; } /** diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index bee8629509b64..d91ad7f8ca2c4 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2777,37 +2777,22 @@ function wp_prefetch_admin_assets(): void { } /* - * 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. + * The URLs are built the same way WP_Scripts::do_item() and WP_Styles::do_item() build + * those in the tags they print, so they match what the next screen will request. They are + * not escaped yet, so the filter sees plain URLs; they are escaped when printed. */ - 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 ); - } + $urls = array( $dependencies->get_src( $handle ) ); - $rtl_href = $dependencies->get_rtl_href( $handle ); + if ( $dependencies instanceof WP_Styles ) { + $rtl_src = $dependencies->get_rtl_src( $handle ); - if ( null !== $rtl_href ) { + if ( null !== $rtl_src ) { if ( 'replace' === $dependencies->get_data( $handle, 'rtl' ) ) { - $urls = array( $rtl_href ); + $urls = array( $rtl_src ); } else { - $urls[] = $rtl_href; + $urls[] = $rtl_src; } } - - /* - * Unlike a script's URL, a stylesheet's comes back escaped for an HTML attribute, with - * `&` as `&`. Decode it so the filter sees plain URLs throughout, and so a - * callback appending the plain form of one is collapsed with it. The URLs are escaped - * again when printed. - */ - $urls = array_map( array( 'WP_HTML_Decoder', 'decode_attribute' ), $urls ); - } else { - $urls = array( $dependencies->get_src( $handle ) ); } foreach ( array_filter( $urls ) as $url ) { diff --git a/tests/phpunit/tests/dependencies/wpStyles/getRtlHref.php b/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php similarity index 83% rename from tests/phpunit/tests/dependencies/wpStyles/getRtlHref.php rename to tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php index 02869fc763ce6..771676a5de33b 100644 --- a/tests/phpunit/tests/dependencies/wpStyles/getRtlHref.php +++ b/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php @@ -1,6 +1,6 @@ styles->add_data( 'test', $key, $value ); } - $this->assertSame( $expected, $this->styles->get_rtl_href( 'test' ) ); + $this->assertSame( $expected, $this->styles->get_rtl_src( 'test' ) ); } /** @@ -69,7 +69,7 @@ public function data_builds_rtl_url(): array { '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' ), + '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' ), ); } @@ -96,7 +96,7 @@ public function test_returns_null( string $text_direction, $src, $rtl ): void { } } - $this->assertNull( $this->styles->get_rtl_href( 'test' ) ); + $this->assertNull( $this->styles->get_rtl_src( 'test' ) ); } /** @@ -135,7 +135,7 @@ static function ( string $src, string $handle ): string { 2 ); - $this->assertSame( 'http://cdn.example.com/wp-admin/css/test-rtl.css?ver=1.0', $this->styles->get_rtl_href( 'test' ) ); + $this->assertSame( 'http://cdn.example.com/wp-admin/css/test-rtl.css?ver=1.0', $this->styles->get_rtl_src( 'test' ) ); $this->assertSame( array( 'http://example.org/wp-admin/css/test.css?ver=1.0', 'test-rtl' ), $filter->get_args()[0] @@ -143,8 +143,9 @@ static function ( string $src, string $handle ): string { } /** - * 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. + * Tests that the URL matches the one {@see WP_Styles::do_item()} prints, once decoded from the + * attribute, whether the right-to-left stylesheet replaces the left-to-right one or loads + * alongside it. * * @ticket 57548 * @@ -154,13 +155,24 @@ static function ( string $src, string $handle ): string { * @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' ); + // A query string of its own, so the printed URL has an `&` that is escaped in the attribute. + $this->styles->add( 'test', '/wp-admin/css/test.css?color=blue', array(), '1.0' ); $this->styles->add_data( 'test', 'rtl', $rtl ); $output = get_echo( array( $this->styles, 'do_item' ), array( 'test' ) ); - $this->assertStringContainsString( "id='test-rtl-css' href='{$this->styles->get_rtl_href( 'test' )}'", $output ); $this->assertSame( $expected, substr_count( $output, "rel='stylesheet'" ) ); + + $processor = new WP_HTML_Tag_Processor( $output ); + $href = null; + while ( $processor->next_tag( 'LINK' ) ) { + if ( 'test-rtl-css' === $processor->get_attribute( 'id' ) ) { + $href = $processor->get_attribute( 'href' ); + } + } + + $this->assertSame( 'http://example.org/wp-admin/css/test-rtl.css?color=blue&ver=1.0', $href ); + $this->assertSame( $href, $this->styles->get_rtl_src( 'test' ) ); } /** diff --git a/tests/phpunit/tests/dependencies/wpStyles/getSrc.php b/tests/phpunit/tests/dependencies/wpStyles/getSrc.php new file mode 100644 index 0000000000000..a6c70e6637a1e --- /dev/null +++ b/tests/phpunit/tests/dependencies/wpStyles/getSrc.php @@ -0,0 +1,205 @@ +styles = new WP_Styles(); + $this->styles->base_url = 'http://example.org'; + $this->styles->content_url = '/wp-content'; + $this->styles->default_version = '7.2'; + } + + /** + * Tests that the URL is built from the source, the base URL and the version. + * + * @ticket 57548 + * + * @dataProvider data_builds_url + * + * @param string $src Source the style is registered with. + * @param string|false|null $ver Version the style is registered with. + * @param string $expected Expected URL. + */ + public function test_builds_url( string $src, $ver, string $expected ): void { + $this->styles->add( 'test', $src, array(), $ver ); + + $this->assertSame( $expected, $this->styles->get_src( 'test' ) ); + } + + /** + * Data provider for {@see self::test_builds_url()}. + * + * @return array + */ + public function data_builds_url(): array { + return array( + 'relative source, default version' => array( '/wp-admin/css/test.css', false, 'http://example.org/wp-admin/css/test.css?ver=7.2' ), + 'relative source, explicit version' => array( '/wp-admin/css/test.css', '1.0', 'http://example.org/wp-admin/css/test.css?ver=1.0' ), + 'relative source, no version' => array( '/wp-admin/css/test.css', null, 'http://example.org/wp-admin/css/test.css' ), + 'absolute source' => array( 'https://cdn.example.com/test.css', '1.0', 'https://cdn.example.com/test.css?ver=1.0' ), + 'protocol-relative source' => array( '//cdn.example.com/test.css', '1.0', '//cdn.example.com/test.css?ver=1.0' ), + 'source under the content URL' => array( '/wp-content/plugins/test/test.css', '1.0', '/wp-content/plugins/test/test.css?ver=1.0' ), + 'source with a query string' => array( 'https://cdn.example.com/test.css?a=1', '1.0', 'https://cdn.example.com/test.css?a=1&ver=1.0' ), + 'source with a fragment' => array( 'https://cdn.example.com/test.css#frag', '1.0', 'https://cdn.example.com/test.css?ver=1.0#frag' ), + 'source with a fragment and no version' => array( 'https://cdn.example.com/test.css#frag', null, 'https://cdn.example.com/test.css#frag' ), + 'version needing to be encoded' => array( 'https://cdn.example.com/test.css', '1.0 beta', 'https://cdn.example.com/test.css?ver=1.0%20beta' ), + ); + } + + /** + * Tests that arguments added to the handle are appended after the version. + * + * @ticket 57548 + */ + public function test_appends_handle_args(): void { + $this->styles->add( 'test', 'https://cdn.example.com/test.css#frag', array(), '1.0' ); + $this->styles->all_deps( 'test?a=1&b=2' ); + + $this->assertSame( 'https://cdn.example.com/test.css?ver=1.0&a=1&b=2#frag', $this->styles->get_src( 'test' ) ); + } + + /** + * Tests that an empty string is returned for a handle that is not registered. + * + * @ticket 57548 + */ + public function test_returns_empty_string_for_unregistered_handle(): void { + $this->assertSame( '', $this->styles->get_src( 'unregistered' ) ); + } + + /** + * Tests that an empty string is returned for a handle that only aliases other handles. + * + * @ticket 57548 + */ + public function test_returns_empty_string_for_alias(): void { + $this->styles->add( 'dependency', '/wp-admin/css/dependency.css' ); + $this->styles->add( 'alias', false, array( 'dependency' ) ); + + $this->assertSame( '', $this->styles->get_src( 'alias' ) ); + } + + /** + * Tests that a handle registered with `true` as its source, as `colors` is, gets its URL from the + * {@see 'style_loader_src'} filter. + * + * @ticket 57548 + */ + public function test_uses_filter_for_source_of_true(): void { + $this->styles->add( 'test', true ); // @phpstan-ignore argument.type (Core registers `colors` this way, though the add() docblock does not allow `true`.) + + add_filter( + 'style_loader_src', + static function ( $src, string $handle ) { + return 'test' === $handle ? 'https://cdn.example.com/scheme.css' : $src; + }, + 10, + 2 + ); + + $this->assertSame( 'https://cdn.example.com/scheme.css', $this->styles->get_src( 'test' ) ); + } + + /** + * Tests that the URL is passed through the {@see 'style_loader_src'} filter along with the handle. + * + * @ticket 57548 + */ + public function test_applies_style_loader_src_filter(): void { + $this->styles->add( 'test', '/wp-admin/css/test.css', array(), '1.0' ); + + $filter = new MockAction(); + add_filter( 'style_loader_src', array( $filter, 'filter' ), 10, 2 ); + add_filter( + 'style_loader_src', + static function ( string $src, string $handle ): string { + return 'test' === $handle ? str_replace( 'example.org', 'cdn.example.com', $src ) : $src; + }, + 20, + 2 + ); + + $this->assertSame( 'http://cdn.example.com/wp-admin/css/test.css?ver=1.0', $this->styles->get_src( 'test' ) ); + $this->assertSame( + array( 'http://example.org/wp-admin/css/test.css?ver=1.0', 'test' ), + $filter->get_args()[0] + ); + } + + /** + * Tests that an empty string is returned when the filter removes the URL. + * + * @ticket 57548 + * + * @dataProvider data_filtered_away + * + * @param mixed $filtered Value the {@see 'style_loader_src'} filter returns. + */ + public function test_returns_empty_string_when_filtered_away( $filtered ): void { + $this->styles->add( 'test', '/wp-admin/css/test.css' ); + + add_filter( + 'style_loader_src', + static function () use ( $filtered ) { + return $filtered; + } + ); + + $this->assertSame( '', $this->styles->get_src( 'test' ) ); + } + + /** + * Data provider for {@see self::test_returns_empty_string_when_filtered_away()}. + * + * @return array + */ + public function data_filtered_away(): array { + return array( + 'empty string' => array( '' ), + 'false' => array( false ), + 'null' => array( null ), + ); + } + + /** + * Tests that the URL matches the one {@see WP_Styles::do_item()} prints, once decoded from the + * attribute, and that {@see WP_Styles::_css_href()} still returns it escaped for the attribute. + * + * @ticket 57548 + */ + public function test_matches_printed_href(): void { + $this->styles->add( 'test', 'https://cdn.example.com/test.css#frag', array(), '1.0' ); + $this->styles->all_deps( 'test?a=1&b=2' ); + + $output = get_echo( array( $this->styles, 'do_item' ), array( 'test' ) ); + + $processor = new WP_HTML_Tag_Processor( $output ); + $this->assertTrue( $processor->next_tag( 'LINK' ) ); + $this->assertSame( $this->styles->get_src( 'test' ), $processor->get_attribute( 'href' ) ); + + $this->assertStringContainsString( "href='https://cdn.example.com/test.css?ver=1.0&a=1&b=2#frag'", $output ); + $this->assertSame( + 'https://cdn.example.com/test.css?ver=1.0&a=1&b=2#frag', + $this->styles->_css_href( 'https://cdn.example.com/test.css#frag', '1.0', 'test' ) + ); + } +} From c0dcfb9344b5a16c1bc5b0e15646722f9a32ecf4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 29 Sep 2026 11:14:12 -0700 Subject: [PATCH 37/71] Check edit_posts rather than create_posts before prefetching the editor The Dashboard and the post list tables prefetch the block editor's stylesheets only for a user who can reach the editor from there. They checked the post type's `create_posts` capability, but the editor is reached by opening an existing post as well as by adding a new one, and a user may be able to do the first without the second. A Contributor on the Patterns screen is one: patterns can only be created with `publish_posts`, but a Contributor can still edit their own. Check `edit_posts` instead, which both routes require, since `post-new.php` requires it along with `create_posts`. On the post list tables it always passes, as `edit.php` requires it too, so it now only keeps editor stylesheets from being prefetched on the Dashboard for users such as Subscribers. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 9 ++++-- .../dependencies/wpPrefetchAdminAssets.php | 29 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index d91ad7f8ca2c4..1d4a28ac5e5a9 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2642,11 +2642,14 @@ function wp_prefetch_admin_assets(): void { } /* - * 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. + * A user who cannot edit posts of this type will never reach the editor from here, and a + * post type still using the classic editor would not load any of these stylesheets. This is + * `edit_posts` rather than `create_posts`, since the editor is reached by opening an existing + * post as well as by adding a new one, and a user may be able to do the first but not the + * second. Adding a new one requires `edit_posts` too. */ if ( - ! current_user_can( $post_type_object->cap->create_posts ) || + ! current_user_can( $post_type_object->cap->edit_posts ) || ! use_block_editor_for_post_type( $post_type ) ) { return; diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index ce16bb9bdc9af..d84188935b452 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -382,20 +382,45 @@ public function data_other_admin_screens(): array { } /** - * Tests that nothing is prefetched for a user who cannot create posts of the type listed. + * Tests that nothing is prefetched for a user who cannot edit posts of the type listed. * * @ticket 57548 * * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_admin_screen_prints_nothing_for_user_who_cannot_create_posts(): void { + public function test_admin_screen_prints_nothing_for_user_who_cannot_edit_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 the editor's stylesheets are prefetched for a user who can edit posts of the type + * listed but not create them, since opening an existing post leads to the editor as well. + * + * A Contributor can edit patterns but not create them, which requires `publish_posts`. + * + * @ticket 57548 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_admin_screen_prefetches_for_user_who_can_edit_but_not_create_posts(): void { + define( 'CONCATENATE_SCRIPTS', false ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'contributor' ) ) ); + + $post_type_object = get_post_type_object( 'wp_block' ); + $this->assertInstanceOf( WP_Post_Type::class, $post_type_object ); + $this->assertTrue( current_user_can( $post_type_object->cap->edit_posts ), 'Expected the user to be able to edit patterns.' ); + $this->assertFalse( current_user_can( $post_type_object->cap->create_posts ), 'Expected the user not to be able to create patterns.' ); + + $links = $this->get_prefetched_on_admin_screen( 'edit-wp_block' ); + + $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/edit-post/style(\.min)?\.css#' ); + } + /** * Tests that nothing is prefetched for a post type that uses the classic editor. * From 89d909c32c3bf066a72230d86c2166dc7783d197 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 29 Sep 2026 12:27:05 -0700 Subject: [PATCH 38/71] Prefetch with fetch() in browsers without rel=prefetch Safari does not support `rel="prefetch"`, so its users got no benefit from the prefetch links. After printing them, print a small inline script which, in browsers where `relList.supports( 'prefetch' )` is false, fetches the URL of each prefetch link on the page once the screen has loaded. The requests ask for low priority, and use `no-cors` mode with credentials, as the next screen's stylesheet and script requests do, and each body is read so the full response is cached. Unlike a prefetch link, a fetch still in flight is canceled when the user navigates away, so it cannot compete with the next screen's own assets. The script reads the links from the page rather than being passed their URLs, so there is one list of them, and prefetch links added by plugins are covered too. In Safari, the responses were confirmed to be reused: every stylesheet and script fetched this way on the login screen was then served from the disk cache on the Dashboard, and every editor stylesheet fetched on the Dashboard was served from the disk cache on Add New Post. Nothing is printed, script included, when no resources are left to prefetch. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 54 ++++++++++++++++++ .../dependencies/wpPrefetchAdminAssets.php | 55 ++++++++++++++++++- 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 1d4a28ac5e5a9..372d566dab5b7 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2872,6 +2872,10 @@ function wp_prefetch_admin_assets(): void { $unique_resources[ $href ] = $as; } + if ( ! $unique_resources ) { + return; + } + // Build and output the HTML for each unique resource. foreach ( $unique_resources as $href => $as ) { printf( @@ -2880,6 +2884,56 @@ function wp_prefetch_admin_assets(): void { esc_attr( $as ) ); } + + /* + * Safari does not support `rel="prefetch"`, so in browsers that lack it, fetch the URLs of the + * page's prefetch links with fetch() instead, to put the responses in the HTTP cache all the + * same. Reading the links from the page, rather than passing their URLs to the script, keeps a + * single list of them, and also covers any prefetch links added by plugins. The fetches wait + * until the current screen has loaded, and ask for low priority, so they stay out of the way + * of its own assets. Unlike a prefetch link, a fetch still in flight is canceled when the user + * navigates away, so it cannot compete with the next screen's render-blocking assets. + * + * The requests use `no-cors` mode and include credentials, as the stylesheet and script + * requests of the next screen do, so the cached responses match what that screen will ask for. + * The body is read to completion so that the full response is cached. + */ + $js_function = <<<'JS' + /** + * Prefetches the page's rel=prefetch links with fetch() in browsers that do not support rel=prefetch. + * + * @see https://caniuse.com/link-rel-prefetch + */ + () => { + if ( document.createElement( 'link' ).relList?.supports?.( 'prefetch' ) ) { + return; + } + + const prefetch = () => { + for ( const link of document.querySelectorAll( 'link[rel~="prefetch"][href]' ) ) { + fetch( link.href, { mode: 'no-cors', credentials: 'include', priority: 'low' } ) + .then( ( response ) => response.blob() ) + .catch( () => {} ); + } + }; + + const schedule = () => { + if ( 'requestIdleCallback' in window ) { + window.requestIdleCallback( prefetch ); + } else { + setTimeout( prefetch, 0 ); + } + }; + + if ( 'complete' === document.readyState ) { + schedule(); + } else { + window.addEventListener( 'load', schedule, { once: true } ); + } + } + JS; + + wp_print_inline_script_tag( "( $js_function )();\n//# sourceURL=" . rawurlencode( __FUNCTION__ ) ); } /** diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index d84188935b452..ed28f5f0bd76d 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -584,7 +584,8 @@ static function (): array { } /** - * Tests that the filter can turn prefetching off. + * Tests that the filter can turn prefetching off, in which case neither the prefetch links nor + * the script prefetching them in browsers that lack `rel="prefetch"` are printed. * * @ticket 57548 * @@ -605,7 +606,41 @@ static function () use ( $filtered ) { } ); - $this->assertSame( array(), $this->get_prefetched_on_login() ); + $this->assertSame( '', $this->get_login_head_output() ); + } + + /** + * Tests that the script prefetching the links in browsers that lack `rel="prefetch"` is printed + * once, after all of the prefetch links it reads from the page. + * + * @ticket 57548 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_prints_polyfill_after_prefetch_links(): void { + define( 'CONCATENATE_SCRIPTS', false ); + + $processor = new WP_HTML_Tag_Processor( $this->get_login_head_output() ); + $links = 0; + $scripts = array(); + $links_after_js = 0; + while ( $processor->next_tag() ) { + if ( 'LINK' === $processor->get_tag() && 'prefetch' === $processor->get_attribute( 'rel' ) ) { + ++$links; + if ( $scripts ) { + ++$links_after_js; + } + } elseif ( 'SCRIPT' === $processor->get_tag() ) { + $scripts[] = $processor->get_modifiable_text(); + } + } + + $this->assertGreaterThan( 0, $links, 'Expected prefetch links to be printed.' ); + $this->assertCount( 1, $scripts, 'Expected one script to be printed.' ); + $this->assertSame( 0, $links_after_js, 'Expected the script to follow all of the prefetch links.' ); + $this->assertStringContainsString( 'link[rel~="prefetch"]', $scripts[0] ); + $this->assertStringContainsString( '//# sourceURL=wp_prefetch_admin_assets', $scripts[0] ); } /** @@ -631,6 +666,20 @@ public function data_filter_turning_off(): array { * @return list Prefetch links in the order printed. */ private function get_prefetched_on_login( array $request = array(), ?string $action = 'login', bool $interim_login = false ): array { + return $this->parse_prefetch_links( $this->get_login_head_output( $request, $action, $interim_login ) ); + } + + /** + * Runs the login screen's prefetching and returns everything it printed. + * + * @param array $request Request parameters of the login screen. + * @param string|null $action Action as resolved by wp-login.php, or null for + * `login_head` fired by a plugin outside of it. + * @param bool $interim_login Whether wp-login.php is displaying the interim + * login modal. + * @return string Printed markup. + */ + private function get_login_head_output( array $request = array(), ?string $action = 'login', bool $interim_login = false ): string { $_REQUEST = $request; $GLOBALS['action'] = $action; @@ -639,7 +688,7 @@ private function get_prefetched_on_login( array $request = array(), ?string $act 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' ) ) ); + return get_echo( 'do_action', array( 'login_head' ) ); } /** From 21a718ebb0ef543ca2233551c32cf4c94966db59 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 1 Oct 2026 22:29:07 -0700 Subject: [PATCH 39/71] Share the concatenation default between script_concat_settings() and prefetching `wp_prefetch_admin_assets()` restated the default from `script_concat_settings()`, so a change to that default, such as turning concatenation off, would leave the prefetch predicting concatenation and printing nothing. Both now call the new `wp_should_concatenate_admin_scripts()`, which applies the `CONCATENATE_SCRIPTS` constant and the `SCRIPT_DEBUG` override and passes the result through a filter of the same name. On the login screen the prefetch still uses the prediction, since the `$concatenate_scripts` global may have been settled there before `login_init`. On admin screens it now reads the global, which has settled by the time the head is printed. This respects a plugin that sets the global directly. The filter lets the prefetch tests turn concatenation off without defining a constant, so they no longer run in separate processes. Only the tests of the constants themselves still do. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 82 +++++-- .../dependencies/wpPrefetchAdminAssets.php | 206 +++++++----------- .../wpShouldConcatenateAdminScripts.php | 112 ++++++++++ 3 files changed, 258 insertions(+), 142 deletions(-) create mode 100644 tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 372d566dab5b7..a6a8aa162219e 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2477,10 +2477,7 @@ function script_concat_settings() { $can_compress_scripts = ! wp_installing() && get_site_option( 'can_compress_scripts' ); if ( ! isset( $concatenate_scripts ) ) { - $concatenate_scripts = defined( 'CONCATENATE_SCRIPTS' ) ? CONCATENATE_SCRIPTS : true; - if ( ( ! is_admin() && ! did_action( 'login_init' ) ) || ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ) { - $concatenate_scripts = false; - } + $concatenate_scripts = ( is_admin() || did_action( 'login_init' ) ) && wp_should_concatenate_admin_scripts(); } if ( ! isset( $compress_scripts ) ) { @@ -2498,6 +2495,38 @@ function script_concat_settings() { } } +/** + * Determines whether scripts and styles are concatenated on admin screens and the login screen. + * + * Concatenation is on unless the `CONCATENATE_SCRIPTS` constant turns it off, and `SCRIPT_DEBUG` + * turns it off regardless. Scripts and styles are never concatenated elsewhere. + * + * This is the default that script_concat_settings() gives the `$concatenate_scripts` global when + * the global has not already been set. It is also how wp_prefetch_admin_assets() predicts, from the + * login screen, what the admin screen the login leads to will do. + * + * @since 7.2.0 + * + * @return bool Whether scripts and styles are concatenated on admin screens and the login screen. + */ +function wp_should_concatenate_admin_scripts(): bool { + $concatenate = ( defined( 'CONCATENATE_SCRIPTS' ) ? (bool) CONCATENATE_SCRIPTS : true ) + && ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ); + + /** + * Filters whether scripts and styles are concatenated on admin screens and the login screen. + * + * Setting the `$concatenate_scripts` global directly still takes precedence over this filter on + * the request where it is set. + * + * @since 7.2.0 + * + * @param bool $concatenate Whether scripts and styles are concatenated. Default true, unless the + * `CONCATENATE_SCRIPTS` constant is false or `SCRIPT_DEBUG` is true. + */ + return (bool) apply_filters( 'wp_should_concatenate_admin_scripts', $concatenate ); +} + /** * Prints prefetch links for the assets of the screen the user is most likely to open next. * @@ -2538,33 +2567,46 @@ function script_concat_settings() { * 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. + * Nothing is printed when the next screen will concatenate its assets, since `load-scripts.php` + * and `load-styles.php` already collapse these handles into a handful of requests. * * @since 7.2.0 * * @see wp_preload_resources() + * @see wp_should_concatenate_admin_scripts() * - * @global 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. + * @global bool $concatenate_scripts Whether scripts and styles are concatenated. + * @global string $action The action that brought the visitor to the login page. + * @global bool|string $interim_login Whether interim login modal is being displayed. String + * 'success' upon successful login. */ function wp_prefetch_admin_assets(): void { - /* - * Deliberately not the $concatenate_scripts global: script_concat_settings() often runs on a - * login request before 'login_init' fires — anything registering a script on 'init' is enough - * to trigger it — and at that point it evaluates is_admin() as false and settles the global on - * false whatever the constant says. What matters here is what the admin screen this login leads - * to will do, which is the constant together with the SCRIPT_DEBUG override. - */ - $admin_will_concatenate = ( defined( 'CONCATENATE_SCRIPTS' ) ? CONCATENATE_SCRIPTS : true ) - && ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ); + global $concatenate_scripts; + + $on_login = ( 'login_head' === current_action() ); + + if ( $on_login ) { + /* + * Deliberately not the $concatenate_scripts global: script_concat_settings() often runs on a + * login request before 'login_init' fires — anything registering a script on 'init' is enough + * to trigger it — and at that point it evaluates is_admin() as false and settles the global on + * false whatever the constant says. What matters here is what the admin screen this login + * leads to will do, which is what wp_should_concatenate_admin_scripts() predicts. + */ + $next_screen_concatenates = wp_should_concatenate_admin_scripts(); + } else { + /* + * On an admin screen the global has settled by the time its head is printed, and the editor + * will be served the same way. + */ + script_concat_settings(); + $next_screen_concatenates = (bool) $concatenate_scripts; + } - if ( $admin_will_concatenate ) { + if ( $next_screen_concatenates ) { return; } - $on_login = ( 'login_head' === current_action() ); $script_roots = array(); $style_roots = array(); diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index ed28f5f0bd76d..5d10b0436419f 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -2,15 +2,9 @@ /** * Tests for {@see wp_prefetch_admin_assets()}. * - * Prefetching only happens when concatenation is off, which is decided by the `CONCATENATE_SCRIPTS` - * and `SCRIPT_DEBUG` constants. A constant cannot be undefined again, so every test defines - * `CONCATENATE_SCRIPTS` in a separate process. `SCRIPT_DEBUG` is already defined by the time a test - * runs, as on when running from `src/`, so tests whose outcome depends on it are skipped when it - * has the other value. - * - * @todo Once concatenation is retired in https://core.trac.wordpress.org/ticket/57548, remove the - * `CONCATENATE_SCRIPTS` constant and the tests that depend on it, and run the rest in the - * main process rather than in separate ones, which are slow. + * Prefetching only happens when concatenation is off. Each test turns it off with the + * {@see 'wp_should_concatenate_admin_scripts'} filter, so that it does not depend on the + * `CONCATENATE_SCRIPTS` and `SCRIPT_DEBUG` constants, which cannot be changed once defined. * * @package WordPress * @subpackage Script Loader @@ -23,40 +17,102 @@ class Tests_Dependencies_WpPrefetchAdminAssets extends WP_UnitTestCase { /** - * Tests that nothing is prefetched when the admin concatenates its assets. - * - * @ticket 57548 + * Globals each test replaces, with the values they had before it. * - * @runInSeparateProcess - * @preserveGlobalState disabled + * @var array + */ + private $original_globals = array(); + + /** + * Gives each test fresh script and style registries, since several tests modify them, and turns + * concatenation off. */ - public function test_prints_nothing_when_concatenating(): void { - define( 'CONCATENATE_SCRIPTS', true ); + public function set_up(): void { + parent::set_up(); - if ( SCRIPT_DEBUG ) { - $this->markTestSkipped( 'SCRIPT_DEBUG is on, which turns off concatenation.' ); + foreach ( array( 'wp_scripts', 'wp_styles', 'concatenate_scripts', 'action', 'interim_login' ) as $name ) { + $this->original_globals[ $name ] = $GLOBALS[ $name ] ?? null; + unset( $GLOBALS[ $name ] ); } + add_filter( 'wp_should_concatenate_admin_scripts', '__return_false' ); + } + + /** + * Restores the globals the test replaced. + */ + public function tear_down(): void { + foreach ( $this->original_globals as $name => $value ) { + if ( null === $value ) { + unset( $GLOBALS[ $name ] ); + } else { + $GLOBALS[ $name ] = $value; + } + } + + parent::tear_down(); + } + + /** + * Tests that nothing is prefetched from the login screen when the admin concatenates its assets. + * + * @ticket 57548 + */ + public function test_login_prints_nothing_when_admin_concatenates(): void { + add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' ); + $this->assertSame( array(), $this->get_prefetched_on_login() ); } /** - * Tests that `SCRIPT_DEBUG` turns off concatenation, and so turns on prefetching, even when - * `CONCATENATE_SCRIPTS` is on. + * Tests that the login screen predicts what the admin will do rather than reading the + * `$concatenate_scripts` global, which script_concat_settings() may have settled on false before + * 'login_init' fired. * * @ticket 57548 + */ + public function test_login_ignores_concatenate_scripts_global(): void { + $GLOBALS['concatenate_scripts'] = true; + + $this->assertNotSame( array(), $this->get_prefetched_on_login(), 'Expected prefetching when the admin will not concatenate.' ); + + $GLOBALS['concatenate_scripts'] = false; + add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' ); + + $this->assertSame( array(), $this->get_prefetched_on_login(), 'Expected no prefetching when the admin will concatenate.' ); + } + + /** + * Tests that an admin screen goes by the `$concatenate_scripts` global, which has settled by the + * time its head is printed, so a plugin setting the global is respected. * - * @runInSeparateProcess - * @preserveGlobalState disabled + * @ticket 57548 */ - public function test_script_debug_overrides_concatenation(): void { - define( 'CONCATENATE_SCRIPTS', true ); + public function test_admin_screen_uses_concatenate_scripts_global(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); - if ( ! SCRIPT_DEBUG ) { - $this->markTestSkipped( 'SCRIPT_DEBUG is off.' ); - } + $GLOBALS['concatenate_scripts'] = true; + + $this->assertSame( array(), $this->get_prefetched_on_admin_screen( 'dashboard' ), 'Expected no prefetching when the global says to concatenate.' ); + + $GLOBALS['concatenate_scripts'] = false; + add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' ); - $this->assertNotSame( array(), $this->get_prefetched_on_login() ); + $this->assertNotSame( array(), $this->get_prefetched_on_admin_screen( 'dashboard' ), 'Expected prefetching when the global says not to concatenate.' ); + } + + /** + * Tests that an admin screen settles the `$concatenate_scripts` global with script_concat_settings() + * when nothing has done so yet. + * + * @ticket 57548 + */ + public function test_admin_screen_settles_concatenate_scripts_global(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' ); + + $this->assertSame( array(), $this->get_prefetched_on_admin_screen( 'dashboard' ) ); + $this->assertTrue( $GLOBALS['concatenate_scripts'] ); } /** @@ -64,13 +120,8 @@ public function test_script_debug_overrides_concatenation(): void { * 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#' ); @@ -98,17 +149,12 @@ public function test_login_prefetches_render_blocking_admin_assets(): void { * * @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#' ); @@ -143,13 +189,8 @@ public function data_editor_destinations(): array { * 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#' ); @@ -161,9 +202,6 @@ public function test_login_prefetches_editor_assets_for_absolute_editor_url(): v * * @ticket 57548 * - * @runInSeparateProcess - * @preserveGlobalState disabled - * * @dataProvider data_login_requests_not_leading_to_admin * * @param array $request Request parameters of the login screen. @@ -171,8 +209,6 @@ public function test_login_prefetches_editor_assets_for_absolute_editor_url(): v * @param bool $interim_login Whether the interim login modal is displayed. */ public function test_login_prints_nothing_when_not_leading_to_admin( array $request, ?string $action, bool $interim_login ): void { - define( 'CONCATENATE_SCRIPTS', false ); - $this->assertSame( array(), $this->get_prefetched_on_login( $request, $action, $interim_login ) ); } @@ -209,13 +245,8 @@ public function data_login_requests_not_leading_to_admin(): array { * 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/' ) ) ) ); } @@ -224,13 +255,8 @@ public function test_login_prints_nothing_for_absolute_front_end_redirect(): voi * 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#' ); @@ -240,13 +266,8 @@ public function test_login_uses_action_resolved_by_wp_login(): void { * 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 ); @@ -262,13 +283,8 @@ public function test_login_off_site_redirect_falls_back_to_admin(): void { * 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 { @@ -288,13 +304,8 @@ static function ( array $hosts ): array { * 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 ); @@ -314,16 +325,12 @@ public function test_login_prints_nothing_for_admin_on_another_port(): void { * * @ticket 57548 * - * @runInSeparateProcess - * @preserveGlobalState disabled - * * @dataProvider data_admin_screens_leading_to_editor * * @param string $screen Screen ID. * @param string $post_type Post type of the editor expected to be prefetched for. */ public function test_admin_screen_prefetches_editor_assets( string $screen, string $post_type ): void { - define( 'CONCATENATE_SCRIPTS', false ); wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); $filter = new MockAction(); @@ -354,15 +361,11 @@ public function data_admin_screens_leading_to_editor(): array { * * @ticket 57548 * - * @runInSeparateProcess - * @preserveGlobalState disabled - * * @dataProvider data_other_admin_screens * * @param string $screen Screen ID. */ public function test_other_admin_screen_prints_nothing( string $screen ): void { - define( 'CONCATENATE_SCRIPTS', false ); wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); $this->assertSame( array(), $this->get_prefetched_on_admin_screen( $screen ) ); @@ -385,12 +388,8 @@ public function data_other_admin_screens(): array { * Tests that nothing is prefetched for a user who cannot edit posts of the type listed. * * @ticket 57548 - * - * @runInSeparateProcess - * @preserveGlobalState disabled */ public function test_admin_screen_prints_nothing_for_user_who_cannot_edit_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' ) ); @@ -403,12 +402,8 @@ public function test_admin_screen_prints_nothing_for_user_who_cannot_edit_posts( * A Contributor can edit patterns but not create them, which requires `publish_posts`. * * @ticket 57548 - * - * @runInSeparateProcess - * @preserveGlobalState disabled */ public function test_admin_screen_prefetches_for_user_who_can_edit_but_not_create_posts(): void { - define( 'CONCATENATE_SCRIPTS', false ); wp_set_current_user( self::factory()->user->create( array( 'role' => 'contributor' ) ) ); $post_type_object = get_post_type_object( 'wp_block' ); @@ -425,12 +420,8 @@ public function test_admin_screen_prefetches_for_user_who_can_edit_but_not_creat * 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' ); @@ -441,13 +432,8 @@ public function test_admin_screen_prints_nothing_for_classic_editor(): void { * 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'; @@ -462,12 +448,8 @@ public function test_skips_assets_already_printed(): void { * 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(); @@ -481,13 +463,8 @@ public function test_prefetches_rtl_stylesheets(): void { * 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'; @@ -534,13 +511,8 @@ static function ( array $link ): bool { * 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 { @@ -589,16 +561,11 @@ static function (): array { * * @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 ) { @@ -614,13 +581,8 @@ static function () use ( $filtered ) { * once, after all of the prefetch links it reads from the page. * * @ticket 57548 - * - * @runInSeparateProcess - * @preserveGlobalState disabled */ public function test_prints_polyfill_after_prefetch_links(): void { - define( 'CONCATENATE_SCRIPTS', false ); - $processor = new WP_HTML_Tag_Processor( $this->get_login_head_output() ); $links = 0; $scripts = array(); diff --git a/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php b/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php new file mode 100644 index 0000000000000..a7fbf3a5dc613 --- /dev/null +++ b/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php @@ -0,0 +1,112 @@ +assertSame( ! SCRIPT_DEBUG, wp_should_concatenate_admin_scripts() ); + } + + /** + * Tests that `CONCATENATE_SCRIPTS` turns concatenation off. + * + * @ticket 57548 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_constant_off(): void { + define( 'CONCATENATE_SCRIPTS', false ); + + $this->assertFalse( wp_should_concatenate_admin_scripts() ); + } + + /** + * Tests the default when `CONCATENATE_SCRIPTS` is not defined, which is to concatenate unless + * `SCRIPT_DEBUG` is on. + * + * @ticket 57548 + */ + public function test_default(): void { + if ( defined( 'CONCATENATE_SCRIPTS' ) ) { + $this->markTestSkipped( 'CONCATENATE_SCRIPTS is defined.' ); + } + + $this->assertSame( ! SCRIPT_DEBUG, wp_should_concatenate_admin_scripts() ); + } + + /** + * Tests that the filter overrides the constants. + * + * @ticket 57548 + */ + public function test_filter(): void { + $filter = new MockAction(); + add_filter( 'wp_should_concatenate_admin_scripts', array( $filter, 'filter' ) ); + + $this->assertSame( ! SCRIPT_DEBUG && ( ! defined( 'CONCATENATE_SCRIPTS' ) || CONCATENATE_SCRIPTS ), wp_should_concatenate_admin_scripts() ); + $this->assertSame( 1, $filter->get_call_count() ); + + add_filter( 'wp_should_concatenate_admin_scripts', '__return_true', 20 ); + $this->assertTrue( wp_should_concatenate_admin_scripts() ); + + add_filter( 'wp_should_concatenate_admin_scripts', '__return_false', 30 ); + $this->assertFalse( wp_should_concatenate_admin_scripts() ); + } + + /** + * Tests that script_concat_settings() takes its default from the function on admin screens, and + * never concatenates elsewhere. + * + * @ticket 57548 + * + * @covers ::script_concat_settings + */ + public function test_script_concat_settings(): void { + global $concatenate_scripts; + + $original = $concatenate_scripts; + add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' ); + + try { + unset( $GLOBALS['concatenate_scripts'] ); + script_concat_settings(); + $this->assertFalse( $GLOBALS['concatenate_scripts'], 'Expected no concatenation on the front end.' ); + + set_current_screen( 'dashboard' ); + unset( $GLOBALS['concatenate_scripts'] ); + script_concat_settings(); + $this->assertTrue( $GLOBALS['concatenate_scripts'], 'Expected concatenation on an admin screen.' ); + + add_filter( 'wp_should_concatenate_admin_scripts', '__return_false', 20 ); + unset( $GLOBALS['concatenate_scripts'] ); + script_concat_settings(); + $this->assertFalse( $GLOBALS['concatenate_scripts'], 'Expected the filter to turn concatenation off on an admin screen.' ); + } finally { + $GLOBALS['concatenate_scripts'] = $original; + } + } +} From 49c1fe564f2d68cb2b67b4fe5f4e91efc59b44a6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 1 Oct 2026 23:58:37 -0700 Subject: [PATCH 40/71] Correct comment formatting in wpShouldConcatenateAdminScripts test Fix formatting issue in comments regarding script concatenation tests. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../tests/dependencies/wpShouldConcatenateAdminScripts.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php b/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php index a7fbf3a5dc613..2eebb7695237b 100644 --- a/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php +++ b/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php @@ -3,7 +3,7 @@ * Tests for {@see wp_should_concatenate_admin_scripts()}. * * A constant cannot be undefined again, so tests that define `CONCATENATE_SCRIPTS` run in a separate - * process. `SCRIPT_DEBUG` is already defined by the time a test runs, as on when running from + * process. `SCRIPT_DEBUG` is already defined by the time a test runs, as when running from * `src/`, so tests whose outcome depends on it are skipped when it has the other value. * * @package WordPress From 88bf79a23f1b48a717e04f7d963ecaf0524d3caa Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 1 Oct 2026 22:39:17 -0700 Subject: [PATCH 41/71] Restore the $concatenate_scripts global in tear_down() The `script_concat_settings()` test backed up and restored the global itself with a `try`/`finally`. The backup now happens in `set_up()` and the restore in `tear_down()`, as in the prefetch tests. When the global was not set before the test, it is unset again rather than left as `null`. Co-Authored-By: Claude Opus 5.5 --- .../wpShouldConcatenateAdminScripts.php | 58 +++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php b/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php index 2eebb7695237b..6030e3965314a 100644 --- a/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php +++ b/tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php @@ -16,6 +16,35 @@ */ class Tests_Dependencies_WpShouldConcatenateAdminScripts extends WP_UnitTestCase { + /** + * Value of the `$concatenate_scripts` global before the test, or null if it was not set. + * + * @var mixed + */ + private $original_concatenate_scripts; + + /** + * Backs up the `$concatenate_scripts` global, which tests of script_concat_settings() change. + */ + public function set_up(): void { + parent::set_up(); + + $this->original_concatenate_scripts = $GLOBALS['concatenate_scripts'] ?? null; + } + + /** + * Restores the `$concatenate_scripts` global. + */ + public function tear_down(): void { + if ( null === $this->original_concatenate_scripts ) { + unset( $GLOBALS['concatenate_scripts'] ); + } else { + $GLOBALS['concatenate_scripts'] = $this->original_concatenate_scripts; + } + + parent::tear_down(); + } + /** * Tests that `CONCATENATE_SCRIPTS` turns concatenation on, unless `SCRIPT_DEBUG` is on. * @@ -86,27 +115,20 @@ public function test_filter(): void { * @covers ::script_concat_settings */ public function test_script_concat_settings(): void { - global $concatenate_scripts; - - $original = $concatenate_scripts; add_filter( 'wp_should_concatenate_admin_scripts', '__return_true' ); - try { - unset( $GLOBALS['concatenate_scripts'] ); - script_concat_settings(); - $this->assertFalse( $GLOBALS['concatenate_scripts'], 'Expected no concatenation on the front end.' ); + unset( $GLOBALS['concatenate_scripts'] ); + script_concat_settings(); + $this->assertFalse( $GLOBALS['concatenate_scripts'], 'Expected no concatenation on the front end.' ); - set_current_screen( 'dashboard' ); - unset( $GLOBALS['concatenate_scripts'] ); - script_concat_settings(); - $this->assertTrue( $GLOBALS['concatenate_scripts'], 'Expected concatenation on an admin screen.' ); + set_current_screen( 'dashboard' ); + unset( $GLOBALS['concatenate_scripts'] ); + script_concat_settings(); + $this->assertTrue( $GLOBALS['concatenate_scripts'], 'Expected concatenation on an admin screen.' ); - add_filter( 'wp_should_concatenate_admin_scripts', '__return_false', 20 ); - unset( $GLOBALS['concatenate_scripts'] ); - script_concat_settings(); - $this->assertFalse( $GLOBALS['concatenate_scripts'], 'Expected the filter to turn concatenation off on an admin screen.' ); - } finally { - $GLOBALS['concatenate_scripts'] = $original; - } + add_filter( 'wp_should_concatenate_admin_scripts', '__return_false', 20 ); + unset( $GLOBALS['concatenate_scripts'] ); + script_concat_settings(); + $this->assertFalse( $GLOBALS['concatenate_scripts'], 'Expected the filter to turn concatenation off on an admin screen.' ); } } From f172c60172dec5808afc277ba266f88754210683 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 20:45:56 -0700 Subject: [PATCH 42/71] Sanitize the right-to-left stylesheet URL only once when printing it `WP_Styles::do_item()` took the right-to-left URL from `get_rtl_src()`, which had already passed it through `esc_url_raw()`, and then escaped it with `esc_url()`. The `clean_url` filter therefore ran twice for that URL, once in the `db` context and once in `display`, where before `get_rtl_src()` was introduced it ran once. A callback that depends on the context or is not idempotent could then make the printed URL differ from the one `get_rtl_src()` returns. The URL building moved into a private `build_rtl_src()` that returns the URL unsanitized. `get_rtl_src()` passes its result through `esc_url_raw()`, and `do_item()` escapes it with `esc_url()`, so each runs the filter once. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/class-wp-styles.php | 24 ++++++++++++-- .../tests/dependencies/wpStyles/getRtlSrc.php | 32 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index 91cab3594788e..fe2e62dea23a7 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -224,7 +224,8 @@ public function do_item( $handle, $group = false ) { */ $tag = apply_filters( 'style_loader_tag', $tag, $handle, $href, $media ); - $rtl_src = $this->get_rtl_src( $handle ); + // Not get_rtl_src(), which would sanitize the URL before it is escaped here, running the 'clean_url' filter twice. + $rtl_src = $this->build_rtl_src( $handle ); if ( null !== $rtl_src ) { $rtl_href = esc_url( $rtl_src ); @@ -277,6 +278,23 @@ public function do_item( $handle, $group = false ) { * not registered, has no source of its own, or has no right-to-left variant. */ public function get_rtl_src( string $handle ): ?string { + $src = $this->build_rtl_src( $handle ); + + return null === $src ? null : esc_url_raw( $src ); + } + + /** + * Builds the URL of the right-to-left stylesheet for a registered style. + * + * The result is not sanitized or escaped, so callers can do either once, for where it is used. + * + * @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, or null when there is none. See {@see WP_Styles::get_rtl_src()}. + */ + private function build_rtl_src( string $handle ): ?string { if ( 'rtl' !== $this->text_direction || ! isset( $this->registered[ $handle ] ) ) { return null; } @@ -300,7 +318,7 @@ public function get_rtl_src( string $handle ): ?string { 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", esc_url_raw( $this->build_src( $obj->src, $ver, "$handle-rtl" ) ) ); + return str_replace( "{$suffix}.css", "-rtl{$suffix}.css", $this->build_src( $obj->src, $ver, "$handle-rtl" ) ); } // Any other value is the URL of the right-to-left stylesheet itself. @@ -308,7 +326,7 @@ public function get_rtl_src( string $handle ): ?string { return null; } - return esc_url_raw( $this->build_src( $obj->extra['rtl'], $ver, "$handle-rtl" ) ); + return $this->build_src( $obj->extra['rtl'], $ver, "$handle-rtl" ); } /** diff --git a/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php b/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php index 771676a5de33b..405415471f00f 100644 --- a/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php +++ b/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php @@ -175,6 +175,38 @@ public function test_matches_printed_href( $rtl, int $expected ): void { $this->assertSame( $href, $this->styles->get_rtl_src( 'test' ) ); } + /** + * Tests that printing a right-to-left stylesheet sanitizes its URL only once, so the + * {@see 'clean_url'} filter runs once for it, as it did before get_rtl_src() was introduced. + * + * @ticket 57548 + * + * @covers WP_Styles::do_item + */ + public function test_printing_runs_clean_url_filter_once(): void { + $this->styles->add( 'test', '/wp-admin/css/test.css', array(), '1.0' ); + $this->styles->add_data( 'test', 'rtl', 'replace' ); + + $filter = new MockAction(); + add_filter( 'clean_url', array( $filter, 'filter' ), 10, 3 ); + + get_echo( array( $this->styles, 'do_item' ), array( 'test' ) ); + + // Once for the left-to-right URL, which is built even when replaced, and once for the right-to-left one. + $this->assertSame( + array( + array( 'http://example.org/wp-admin/css/test.css?ver=1.0', 'display' ), + array( 'http://example.org/wp-admin/css/test-rtl.css?ver=1.0', 'display' ), + ), + array_map( + static function ( array $args ): array { + return array( $args[0], $args[2] ); + }, + $filter->get_args() + ) + ); + } + /** * Data provider for {@see self::test_matches_printed_href()}. * From b00c19c2136dcc037bfa5407b2032c1448d32f9a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 20:48:41 -0700 Subject: [PATCH 43/71] Skip prefetching from the login screen for an admin under another scheme The login screen already skipped a `redirect_to` pointing at an admin on another host or port, since that admin would request its assets at other URLs. The same is true of this site's admin under another scheme, such as an `https` destination from an `http` login: the URLs prefetched take the scheme of the current request, so none of them would be reused. Such a destination is now skipped too. A protocol-relative `redirect_to` keeps the login screen's scheme, so it still prefetches. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 16 ++++++--- .../dependencies/wpPrefetchAdminAssets.php | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index a6a8aa162219e..8379c3abf74a1 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2651,16 +2651,24 @@ function wp_prefetch_admin_assets(): void { /* * 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. + * admin would request its assets from its own host rather than from this one. The same goes + * for this host's admin under another scheme, such as an `https` destination from an `http` + * login: the URLs prefetched here take the scheme of the current request, so that admin + * would request different ones. A relative `redirect_to` stays on this host and scheme, and + * a protocol-relative one on this scheme. */ - $next_screen_host = wp_parse_url( $next_screen, PHP_URL_HOST ); + $next_screen_host = wp_parse_url( $next_screen, PHP_URL_HOST ); + $next_screen_scheme = wp_parse_url( $next_screen, PHP_URL_SCHEME ); 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 ) + wp_parse_url( $next_screen, PHP_URL_PORT ) !== wp_parse_url( admin_url(), PHP_URL_PORT ) || + ( + is_string( $next_screen_scheme ) && + strtolower( $next_screen_scheme ) !== strtolower( (string) wp_parse_url( admin_url(), PHP_URL_SCHEME ) ) + ) ) ) { return; diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 5d10b0436419f..11cfaa06dcdbd 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -320,6 +320,39 @@ public function test_login_prints_nothing_for_admin_on_another_port(): void { ); } + /** + * Tests that nothing is prefetched when the login lands in this site's admin under another scheme, + * since the URLs prefetched take the scheme of the login screen and that admin would request + * different ones. + * + * @ticket 57548 + */ + public function test_login_prints_nothing_for_admin_on_another_scheme(): void { + $scheme = (string) wp_parse_url( admin_url(), PHP_URL_SCHEME ); + $other_scheme = 'https' === $scheme ? 'http' : 'https'; + + $this->assertSame( + array(), + $this->get_prefetched_on_login( array( 'redirect_to' => set_url_scheme( admin_url( 'post-new.php' ), $other_scheme ) ) ) + ); + } + + /** + * Tests that a protocol-relative `redirect_to` pointing at this site's admin prefetches, since it + * keeps the scheme of the login screen. + * + * @ticket 57548 + */ + public function test_login_prefetches_for_protocol_relative_admin_url(): void { + $redirect_to = (string) preg_replace( '#^https?:#', '', admin_url( 'post-new.php' ) ); + $this->assertStringStartsWith( '//', $redirect_to ); + + $links = $this->get_prefetched_on_login( array( 'redirect_to' => $redirect_to ) ); + + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); + $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/edit-post/style(\.min)?\.css#' ); + } + /** * Tests that the Dashboard and the post list tables prefetch only the editor's stylesheets. * From 9b752fef0cf33346b17a6fe7bc51d32abbd1b270 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 20:52:26 -0700 Subject: [PATCH 44/71] Skip prefetch URLs that esc_url() rejects A URL from the `prefetch_admin_assets` filter was checked for being empty only before it was escaped, so one that `esc_url()` rejects, such as a `javascript:` URL, was printed with an empty `href`. In browsers without `rel="prefetch"`, the script that fetches the page's prefetch links resolves an empty `href` to the current page, and so fetched the login or admin screen itself. Each URL is now escaped while the list is collected and checked for being empty again afterward. Duplicates are collapsed by the escaped URL, and the links print it without escaping it a second time. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 12 +++++++++--- .../tests/dependencies/wpPrefetchAdminAssets.php | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 8379c3abf74a1..6ae929644bb7b 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2915,7 +2915,13 @@ function wp_prefetch_admin_assets(): void { continue; } - if ( isset( $unique_resources[ $href ] ) ) { + /* + * Check again once escaped, since esc_url() returns an empty string for a URL it rejects. + * An empty `href` would resolve to the current page, which the script below would then fetch. + */ + $href = esc_url( $href ); + + if ( '' === $href || isset( $unique_resources[ $href ] ) ) { continue; } @@ -2926,11 +2932,11 @@ function wp_prefetch_admin_assets(): void { return; } - // Build and output the HTML for each unique resource. + // Build and output the HTML for each unique resource. Each URL has already been escaped. foreach ( $unique_resources as $href => $as ) { printf( "\n", - esc_url( $href ), + $href, esc_attr( $as ) ); } diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 11cfaa06dcdbd..300fa6122af74 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -568,6 +568,11 @@ static function (): array { 'href' => '', 'as' => 'script', ), + // Rejected by esc_url(), so it would otherwise be printed with an empty `href`. + array( + 'href' => 'javascript:alert(1)', + 'as' => 'script', + ), 'not an array', ); } From 59416962a85333e2c66a020dda1fb61bdd5e70dc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 22:06:07 -0700 Subject: [PATCH 45/71] Prefetch from the login screen after its footer scripts The login screen's prefetching ran on `login_head`, before wp-login.php enqueues `user-profile` for the login form. That script depends on `jquery`, so the login screen loads `jquery-core` and `jquery-migrate` in its own footer, and those were still prefetched in the head: a low-priority prefetch competing with the page's own requests for the same files. It now runs on `login_footer` at priority 21, just after `wp_print_footer_scripts()`, so everything the login screen loads is in `done` and skipped. On a default install the login screen prints 19 prefetch links rather than 21. Admin screens still prefetch from the head. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/default-filters.php | 2 +- src/wp-includes/script-loader.php | 11 ++- .../dependencies/wpPrefetchAdminAssets.php | 85 ++++++++++++++----- 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index f3c2fca81633b..162ed2a7daa6e 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -396,10 +396,10 @@ add_action( 'login_head', 'wp_resource_hints', 8 ); add_action( 'login_head', 'wp_print_head_scripts', 9 ); add_action( 'login_head', 'print_admin_styles', 9 ); -add_action( 'login_head', 'wp_prefetch_admin_assets' ); add_action( 'admin_head', 'wp_prefetch_admin_assets' ); add_action( 'login_head', 'wp_site_icon', 99 ); add_action( 'login_footer', 'wp_print_footer_scripts', 20 ); +add_action( 'login_footer', 'wp_prefetch_admin_assets', 21 ); // After footer scripts, so those the login screen loads itself are skipped. add_action( 'login_init', 'send_frame_options_header', 10, 0 ); add_action( 'login_init', 'wp_admin_headers' ); diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 6019691813efd..27c01e38f1b59 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2544,7 +2544,12 @@ function wp_should_concatenate_admin_scripts(): bool { * 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. + * is actually adding. On the login screen this runs in the footer, after the footer scripts, since + * scripts the login form enqueues after its header has printed, such as `user-profile`, bring in + * some of the same handles, like `jquery`. A prefetch of those in the head would compete with the + * login screen's own requests for them. On admin screens it runs in the head, where only the + * editor's stylesheets are prefetched, and a screen prints nearly all of its own stylesheets in + * the head as well. * * 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 @@ -2583,7 +2588,7 @@ function wp_should_concatenate_admin_scripts(): bool { function wp_prefetch_admin_assets(): void { global $concatenate_scripts; - $on_login = ( 'login_head' === current_action() ); + $on_login = ( 'login_footer' === current_action() ); if ( $on_login ) { /* @@ -2613,7 +2618,7 @@ function wp_prefetch_admin_assets(): void { 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 + * logout confirmation and check-your-email flows all render through 'login_footer' 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. * diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 300fa6122af74..97ee1744d6a67 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -221,10 +221,10 @@ public function test_login_prints_nothing_when_not_leading_to_admin( array $requ */ public function data_login_requests_not_leading_to_admin(): array { return array( - 'lost password' => array( array( 'action' => 'lostpassword' ), 'lostpassword', false ), - 'registration' => array( array( 'action' => 'register' ), 'register', false ), - 'logout' => array( array( 'action' => 'logout' ), 'logout', false ), - 'password reset key' => array( + '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', @@ -232,12 +232,12 @@ public function data_login_requests_not_leading_to_admin(): array { 'resetpass', false, ), - 'check email' => array( array( 'checkemail' => 'confirm' ), 'checkemail', false ), - 'check email after register' => array( array( 'checkemail' => 'registered' ), 'checkemail', false ), - 'interim login' => array( array( 'interim-login' => '1' ), 'login', true ), - 'login_head fired by plugin' => array( array(), null, false ), - 'front end redirect' => array( array( 'redirect_to' => '/hello-world/' ), 'login', false ), - 'lookalike admin path' => array( array( 'redirect_to' => '/wp-admin-lookalike/' ), 'login', false ), + '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_footer fired by plugin' => array( array(), null, false ), + 'front end redirect' => array( array( 'redirect_to' => '/hello-world/' ), 'login', false ), + 'lookalike admin path' => array( array( 'redirect_to' => '/wp-admin-lookalike/' ), 'login', false ), ); } @@ -477,6 +477,53 @@ public function test_skips_assets_already_printed(): void { $this->assertPrefetched( $links, 'style', '#/wp-admin/css/forms(\.min)?\.css#' ); } + /** + * Tests that the login screen skips scripts it loads itself in the footer. + * + * wp-login.php enqueues `user-profile` after its header has printed, and that script brings in + * `jquery`, so the login screen loads jQuery in its footer. The prefetching runs after the + * footer scripts, as registered in default-filters.php, so it leaves jQuery out but still + * prefetches `utils`, which the login screen does not load. + * + * @ticket 57548 + */ + public function test_login_skips_scripts_printed_in_footer(): void { + $GLOBALS['action'] = 'login'; + $GLOBALS['interim_login'] = false; + + remove_all_actions( 'login_footer' ); + add_action( 'login_footer', 'wp_print_footer_scripts', 20 ); + add_action( 'login_footer', 'wp_prefetch_admin_assets', 21 ); + + wp_enqueue_script( 'user-profile' ); + + $output = get_echo( 'do_action', array( 'login_footer' ) ); + $links = $this->parse_prefetch_links( $output ); + + $this->assertMatchesRegularExpression( '#]+src=[\'"][^\'"]*/wp-includes/js/jquery/jquery(\.min)?\.js#', $output, 'Expected the login screen to load jQuery itself.' ); + $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery(\.min)?\.js#' ); + $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery-migrate(\.min)?\.js#' ); + $this->assertPrefetched( $links, 'script', '#/wp-includes/js/utils(\.min)?\.js#' ); + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); + } + + /** + * Tests that the prefetching from the login screen is hooked to run after its footer scripts. + * + * @ticket 57548 + * + * @coversNothing + */ + public function test_login_hook_follows_footer_scripts(): void { + $prefetch_priority = has_action( 'login_footer', 'wp_prefetch_admin_assets' ); + $scripts_priority = has_action( 'login_footer', 'wp_print_footer_scripts' ); + + $this->assertIsInt( $prefetch_priority ); + $this->assertIsInt( $scripts_priority ); + $this->assertGreaterThan( $scripts_priority, $prefetch_priority ); + $this->assertFalse( has_action( 'login_head', 'wp_prefetch_admin_assets' ) ); + } + /** * Tests that a right-to-left locale prefetches the right-to-left stylesheets. * @@ -611,7 +658,7 @@ static function () use ( $filtered ) { } ); - $this->assertSame( '', $this->get_login_head_output() ); + $this->assertSame( '', $this->get_login_footer_output() ); } /** @@ -621,7 +668,7 @@ static function () use ( $filtered ) { * @ticket 57548 */ public function test_prints_polyfill_after_prefetch_links(): void { - $processor = new WP_HTML_Tag_Processor( $this->get_login_head_output() ); + $processor = new WP_HTML_Tag_Processor( $this->get_login_footer_output() ); $links = 0; $scripts = array(); $links_after_js = 0; @@ -660,13 +707,13 @@ public function data_filter_turning_off(): array { * * @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. + * `login_footer` fired by a plugin outside of it. * @param bool $interim_login Whether wp-login.php is displaying the interim * login modal. * @return list Prefetch links in the order printed. */ private function get_prefetched_on_login( array $request = array(), ?string $action = 'login', bool $interim_login = false ): array { - return $this->parse_prefetch_links( $this->get_login_head_output( $request, $action, $interim_login ) ); + return $this->parse_prefetch_links( $this->get_login_footer_output( $request, $action, $interim_login ) ); } /** @@ -674,21 +721,21 @@ private function get_prefetched_on_login( array $request = array(), ?string $act * * @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. + * `login_footer` fired by a plugin outside of it. * @param bool $interim_login Whether wp-login.php is displaying the interim * login modal. * @return string Printed markup. */ - private function get_login_head_output( array $request = array(), ?string $action = 'login', bool $interim_login = false ): string { + private function get_login_footer_output( array $request = array(), ?string $action = 'login', bool $interim_login = false ): string { $_REQUEST = $request; $GLOBALS['action'] = $action; $GLOBALS['interim_login'] = $interim_login; - remove_all_actions( 'login_head' ); - add_action( 'login_head', 'wp_prefetch_admin_assets' ); + remove_all_actions( 'login_footer' ); + add_action( 'login_footer', 'wp_prefetch_admin_assets' ); - return get_echo( 'do_action', array( 'login_head' ) ); + return get_echo( 'do_action', array( 'login_footer' ) ); } /** From 329b924d4e626b4113a7db4bc5a181c9b0b2073b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 22:11:23 -0700 Subject: [PATCH 46/71] Hook admin prefetching in admin-filters.php `wp_prefetch_admin_assets()` was hooked to `admin_head` in default-filters.php, which loads on every request. On an admin screen it calls `get_current_screen()`, which is only defined once the admin includes are loaded. A request that fires `admin_head` outside the admin without loading them would have hit a fatal error. The hook now lives in wp-admin/includes/admin-filters.php, alongside core's other `admin_head` callbacks, so it is only added where those includes are loaded. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/includes/admin-filters.php | 1 + src/wp-includes/default-filters.php | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/admin-filters.php b/src/wp-admin/includes/admin-filters.php index 5337cc02c88c9..e1943da43993e 100644 --- a/src/wp-admin/includes/admin-filters.php +++ b/src/wp-admin/includes/admin-filters.php @@ -48,6 +48,7 @@ add_action( 'admin_head', 'wp_admin_canonical_url' ); add_action( 'admin_head', 'wp_site_icon' ); add_action( 'admin_head', 'wp_admin_viewport_meta' ); +add_action( 'admin_head', 'wp_prefetch_admin_assets' ); add_action( 'customize_controls_head', 'wp_admin_viewport_meta' ); add_filter( 'nav_menu_meta_box_object', '_wp_nav_menu_meta_box_object' ); diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 162ed2a7daa6e..4c8a67e9dd955 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -396,7 +396,6 @@ 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( '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_footer', 'wp_prefetch_admin_assets', 21 ); // After footer scripts, so those the login screen loads itself are skipped. From 34746c5298888af851bf9ee9a7187aebb6a692a0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 22:14:57 -0700 Subject: [PATCH 47/71] Check the block editor before prefetching its stylesheets from the login screen A login whose `redirect_to` pointed at post-new.php, or at post.php to edit a post, prefetched the block editor's stylesheets even when the post type uses the classic editor, as it does on a site running the Classic Editor plugin. The admin screens already checked `use_block_editor_for_post_type()`, but the login screen did not, on the assumption that the function is only available in the admin. It has been in wp-includes since 6.1, and the Classic Editor plugin filters it on every request. The login screen now checks it for the post type in `redirect_to`. An edit link to post.php does not name its post type, so it is taken to be a post. The post is deliberately not looked up, since that would let anyone tell from the login screen whether a post with a given ID exists, drafts and private posts included. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 13 ++++ .../dependencies/wpPrefetchAdminAssets.php | 67 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 27c01e38f1b59..b4ca1c8546bfd 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2766,6 +2766,19 @@ function wp_prefetch_admin_assets(): void { $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 && $on_login ) { + /* + * The editor only loads these stylesheets when the post type uses the block editor, which + * the classic editor, for instance, can turn off. The admin screens have checked this + * already. From the login screen, the post type is the one in `redirect_to`, as for + * post-new.php. An edit link to post.php does not carry one, so it is taken to be a post: + * looking the post up instead would let anyone tell from the login screen whether a post + * with a given ID exists, drafts and private posts included. + */ + $next_screen_post_type = $next_screen_query['post_type'] ?? 'post'; + $next_screen_is_block_editor = is_string( $next_screen_post_type ) && use_block_editor_for_post_type( $next_screen_post_type ); + } + if ( $next_screen_is_block_editor ) { /* * Roots as well, expanded along with any from the login screen. `wp-edit-post` alone accounts diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 97ee1744d6a67..f006dcd8648f5 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -197,6 +197,73 @@ public function test_login_prefetches_editor_assets_for_absolute_editor_url(): v $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/edit-post/style(\.min)?\.css#' ); } + /** + * Tests that a login leading to the editor of a post type that does not use the block editor + * prefetches only the admin's stylesheets, not the block editor's. + * + * The classic editor is turned off for pages only, so the post type is what decides. An edit + * link to post.php does not name its post type, so it is taken to be a post. + * + * @ticket 57548 + * + * @dataProvider data_editor_destinations_by_post_type + * + * @param non-falsy-string $redirect_to Where the login redirects to. + * @param bool $is_editor Whether that is expected to be the block editor. + */ + public function test_login_checks_block_editor_for_post_type( string $redirect_to, bool $is_editor ): void { + add_filter( + 'use_block_editor_for_post_type', + static function ( bool $use_block_editor, string $post_type ): bool { + return 'page' === $post_type ? false : $use_block_editor; + }, + 10, + 2 + ); + + $links = $this->get_prefetched_on_login( array( 'redirect_to' => $redirect_to ) ); + + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); + + if ( $is_editor ) { + $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/edit-post/style(\.min)?\.css#' ); + } else { + $this->assertNotPrefetched( $links, '#/wp-includes/css/dist/edit-post/#' ); + } + } + + /** + * Data provider for {@see self::test_login_checks_block_editor_for_post_type()}. + * + * @return array + */ + public function data_editor_destinations_by_post_type(): array { + return array( + 'new post' => array( '/wp-admin/post-new.php', true ), + 'new page' => array( '/wp-admin/post-new.php?post_type=page', false ), + 'unregistered post type' => array( '/wp-admin/post-new.php?post_type=nonexistent', false ), + 'post type not a string' => array( '/wp-admin/post-new.php?post_type[]=post', false ), + 'editing a post' => array( '/wp-admin/post.php?post=1&action=edit', true ), + ); + } + + /** + * Tests that a login leading to the editor prefetches only the admin's stylesheets when the + * classic editor replaces the block editor for every post type. + * + * @ticket 57548 + */ + public function test_login_prints_no_editor_assets_for_classic_editor(): void { + add_filter( 'use_block_editor_for_post_type', '__return_false' ); + + foreach ( array( '/wp-admin/post-new.php', '/wp-admin/post.php?post=1&action=edit' ) as $redirect_to ) { + $links = $this->get_prefetched_on_login( array( 'redirect_to' => $redirect_to ) ); + + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); + $this->assertNotPrefetched( $links, '#/wp-includes/css/dist/edit-post/#' ); + } + } + /** * Tests that nothing is prefetched from login screens that do not lead to the admin. * From 52bfff2d19e0682ec9925d62825238d7a78cc4b5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 22:50:27 -0700 Subject: [PATCH 48/71] Tell the login screen apart by the request, and prefetch on all of its screens `wp_prefetch_admin_assets()` treated a call from `login_footer` as the login screen and anything else as an admin screen, where it called `get_current_screen()`. If the callback was moved to another hook on wp-login.php, such as back to `login_head`, it took the admin branch and failed with a fatal error, since `get_current_screen()` is not defined outside the admin. The same happened on any other request that called the function without the admin includes loaded, such as one for the front end. The login screen is now recognized with `is_login()`, so the function works from whichever hook it is added to. The admin branch reads the `$current_screen` global, which is all `get_current_screen()` does, and prints nothing when there is no screen. The context is checked before concatenation, so a request with no screen does not call `script_concat_settings()`. The login screen also no longer limits prefetching to the login form, which removes the need for the `$action` and `$interim_login` globals. The other screens mostly lead to the admin as well: the password reset and registration flows end at the login form, and the admin email confirmation follows a login that has already succeeded. Prefetching on them gives the downloads more time to finish before the user gets there. An interim login shows inside a modal on an admin screen that has already loaded these assets, so its prefetches are served from the HTTP cache. A `redirect_to` pointing outside the admin still prints nothing. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 82 ++++----- .../dependencies/wpPrefetchAdminAssets.php | 174 +++++++++++++----- 2 files changed, 163 insertions(+), 93 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index b4ca1c8546bfd..369938757bdfd 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2567,10 +2567,9 @@ function wp_should_concatenate_admin_scripts(): bool { * screen will later ask for, which is what lets the prefetched response be reused. * * The admin-wide handles cover every admin screen rather than only the Dashboard, so that part of - * the list does not vary with where the login lands. Nothing is printed at all when the login is - * not going to lead to an admin screen: on the password reset, registration, logout and - * check-your-email flows, on an interim login, or when `redirect_to` points outside this site's - * admin. + * the list does not vary with where the login lands. They are printed on every screen of the login + * page, such as the password reset form, since those mostly lead to the admin as well. Nothing is + * printed at all when `redirect_to` points outside this site's admin. * * Nothing is printed when the next screen will concatenate its assets, since `load-scripts.php` * and `load-styles.php` already collapse these handles into a handful of requests. @@ -2580,15 +2579,17 @@ function wp_should_concatenate_admin_scripts(): bool { * @see wp_preload_resources() * @see wp_should_concatenate_admin_scripts() * - * @global bool $concatenate_scripts Whether scripts and styles are concatenated. - * @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. + * @global bool $concatenate_scripts Whether scripts and styles are concatenated. + * @global WP_Screen|null $current_screen The current admin screen, if any. */ function wp_prefetch_admin_assets(): void { - global $concatenate_scripts; + global $concatenate_scripts, $current_screen; - $on_login = ( 'login_footer' === current_action() ); + /* + * The context is told apart by the request rather than by the hook this runs on, so it works + * from whichever hook it is added to. + */ + $on_login = is_login(); if ( $on_login ) { /* @@ -2598,44 +2599,51 @@ function wp_prefetch_admin_assets(): void { * false whatever the constant says. What matters here is what the admin screen this login * leads to will do, which is what wp_should_concatenate_admin_scripts() predicts. */ - $next_screen_concatenates = wp_should_concatenate_admin_scripts(); + if ( wp_should_concatenate_admin_scripts() ) { + return; + } } else { + /* + * From the Dashboard and the post list tables, the editor is the usual next stop. Anywhere + * else in the admin there is no destination worth guessing at. + * + * The global is read rather than calling get_current_screen(), which only exists once the + * admin includes are loaded. On a request with no screen, such as one for the front end, + * this prints nothing. + */ + if ( ! $current_screen instanceof WP_Screen || ! in_array( $current_screen->base, array( 'dashboard', 'edit' ), true ) ) { + return; + } + /* * On an admin screen the global has settled by the time its head is printed, and the editor * will be served the same way. */ script_concat_settings(); - $next_screen_concatenates = (bool) $concatenate_scripts; - } - if ( $next_screen_concatenates ) { - return; + if ( $concatenate_scripts ) { + return; + } } $script_roots = array(); $style_roots = array(); if ( $on_login ) { - /* - * Only the login form is followed by an admin screen. The password reset, registration, - * logout confirmation and check-your-email flows all render through 'login_footer' too, and - * none of them leads anywhere these assets are wanted. An interim login re-authenticates - * inside a modal on a page that has already loaded them, so it does not need them either. - * - * The action is the one wp-login.php has already resolved rather than the request parameter, - * which it overrides: a `key` switches to the password reset form and `checkemail` to the - * check-your-email message, while an action it does not recognize falls back to the login form. - */ - global $action, $interim_login; - - if ( 'login' !== $action || $interim_login ) { - return; - } - /* * Resolve where the login is going to land, the same way wp-login.php will: `redirect_to` * when one was given, and the admin otherwise. wp_validate_redirect() mirrors what * wp_safe_redirect() does with a value pointing off-host, which is to fall back to the admin. + * + * This runs on every screen wp-login.php prints, not only the login form, since the others + * mostly lead to the admin as well, and each one gives the prefetching another chance to + * finish before the user gets there. The password reset and registration flows end at the + * login form, and the admin email confirmation follows a login that has already succeeded. + * On the lost password and registration screens, `redirect_to` is where submitting the + * form goes rather than where the login lands, but it is normally absent, and when it is + * not, it typically points back into wp-login.php, so nothing is prefetched. Nor does an + * interim login need to be excluded: it shows inside a modal on an admin screen that has + * already loaded these assets, so they are served from the HTTP cache. */ $next_screen = admin_url(); @@ -2679,17 +2687,7 @@ function wp_prefetch_admin_assets(): void { 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 = ( 'edit' === $current_screen->base && $current_screen->post_type ) ? $current_screen->post_type : 'post'; $post_type_object = get_post_type_object( $post_type ); if ( ! $post_type_object instanceof WP_Post_Type ) { diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index f006dcd8648f5..b0bd46c24f8b2 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -23,6 +23,13 @@ class Tests_Dependencies_WpPrefetchAdminAssets extends WP_UnitTestCase { */ private $original_globals = array(); + /** + * Name of the script handling the request before the test, which tests on the login screen change. + * + * @var mixed + */ + private $original_script_name; + /** * Gives each test fresh script and style registries, since several tests modify them, and turns * concatenation off. @@ -30,11 +37,13 @@ class Tests_Dependencies_WpPrefetchAdminAssets extends WP_UnitTestCase { public function set_up(): void { parent::set_up(); - foreach ( array( 'wp_scripts', 'wp_styles', 'concatenate_scripts', 'action', 'interim_login' ) as $name ) { + foreach ( array( 'wp_scripts', 'wp_styles', 'concatenate_scripts' ) as $name ) { $this->original_globals[ $name ] = $GLOBALS[ $name ] ?? null; unset( $GLOBALS[ $name ] ); } + $this->original_script_name = $_SERVER['SCRIPT_NAME'] ?? null; + add_filter( 'wp_should_concatenate_admin_scripts', '__return_false' ); } @@ -50,6 +59,12 @@ public function tear_down(): void { } } + if ( null === $this->original_script_name ) { + unset( $_SERVER['SCRIPT_NAME'] ); + } else { + $_SERVER['SCRIPT_NAME'] = $this->original_script_name; + } + parent::tear_down(); } @@ -265,68 +280,96 @@ public function test_login_prints_no_editor_assets_for_classic_editor(): void { } /** - * Tests that nothing is prefetched from login screens that do not lead to the admin. + * Tests that every screen of the login page prefetches the admin's assets, not only the login + * form, since the others mostly lead to the admin as well. * * @ticket 57548 * - * @dataProvider data_login_requests_not_leading_to_admin + * @dataProvider data_login_screens * - * @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. + * @param array $request Request parameters of the login screen. */ - public function test_login_prints_nothing_when_not_leading_to_admin( array $request, ?string $action, bool $interim_login ): void { - $this->assertSame( array(), $this->get_prefetched_on_login( $request, $action, $interim_login ) ); + public function test_login_prefetches_on_every_login_screen( array $request ): void { + $links = $this->get_prefetched_on_login( $request ); + + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); } /** - * Data provider for {@see self::test_login_prints_nothing_when_not_leading_to_admin()}. + * Data provider for {@see self::test_login_prefetches_on_every_login_screen()}. * - * The action is the one wp-login.php resolves for the request. - * - * @return array, 1: string|null, 2: bool }> + * @return array }> */ - public function data_login_requests_not_leading_to_admin(): array { + public function data_login_screens(): array { return array( - 'lost password' => array( array( 'action' => 'lostpassword' ), 'lostpassword', false ), - 'registration' => array( array( 'action' => 'register' ), 'register', false ), - 'logout' => array( array( 'action' => 'logout' ), 'logout', false ), - 'password reset key' => array( + 'login form' => array( array() ), + 'lost password' => array( array( 'action' => 'lostpassword' ) ), + 'registration' => array( array( 'action' => 'register' ) ), + '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_footer fired by plugin' => array( array(), null, false ), - 'front end redirect' => array( array( 'redirect_to' => '/hello-world/' ), 'login', false ), - 'lookalike admin path' => array( array( 'redirect_to' => '/wp-admin-lookalike/' ), 'login', false ), + 'check email' => array( array( 'checkemail' => 'confirm' ) ), + 'interim login' => array( array( 'interim-login' => '1' ) ), + 'unrecognized action' => array( array( 'action' => 'unrecognized' ) ), + 'admin email reminder' => array( + array( + 'action' => 'confirm_admin_email', + 'redirect_to' => '/wp-admin/', + ), + ), ); } /** - * Tests that nothing is prefetched when an absolute `redirect_to` points at this site's front end. + * Tests that nothing is prefetched from login screens that do not lead to the admin. * * @ticket 57548 + * + * @dataProvider data_login_requests_not_leading_to_admin + * + * @param array $request Request parameters of the login screen. */ - public function test_login_prints_nothing_for_absolute_front_end_redirect(): void { - $this->assertSame( array(), $this->get_prefetched_on_login( array( 'redirect_to' => home_url( '/hello-world/' ) ) ) ); + public function test_login_prints_nothing_when_not_leading_to_admin( array $request ): void { + $this->assertSame( array(), $this->get_prefetched_on_login( $request ) ); } /** - * 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. + * Data provider for {@see self::test_login_prints_nothing_when_not_leading_to_admin()}. * - * @ticket 57548 + * On the lost password and registration screens, `redirect_to` is where submitting the form + * goes, which is typically back into wp-login.php. + * + * @return array }> */ - public function test_login_uses_action_resolved_by_wp_login(): void { - $links = $this->get_prefetched_on_login( array( 'action' => 'unrecognized' ), 'login' ); + public function data_login_requests_not_leading_to_admin(): array { + return array( + 'front end redirect' => array( array( 'redirect_to' => '/hello-world/' ) ), + 'lookalike admin path' => array( array( 'redirect_to' => '/wp-admin-lookalike/' ) ), + 'lost password redirecting to login' => array( + array( + 'action' => 'lostpassword', + 'redirect_to' => 'wp-login.php?checkemail=confirm', + ), + ), + 'registration redirecting to login' => array( + array( + 'action' => 'register', + 'redirect_to' => 'wp-login.php?checkemail=registered', + ), + ), + ); + } - $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); + /** + * Tests that nothing is prefetched when an absolute `redirect_to` points at this site's front end. + * + * @ticket 57548 + */ + public function test_login_prints_nothing_for_absolute_front_end_redirect(): void { + $this->assertSame( array(), $this->get_prefetched_on_login( array( 'redirect_to' => home_url( '/hello-world/' ) ) ) ); } /** @@ -555,8 +598,7 @@ public function test_skips_assets_already_printed(): void { * @ticket 57548 */ public function test_login_skips_scripts_printed_in_footer(): void { - $GLOBALS['action'] = 'login'; - $GLOBALS['interim_login'] = false; + $this->go_to_login_screen(); remove_all_actions( 'login_footer' ); add_action( 'login_footer', 'wp_print_footer_scripts', 20 ); @@ -574,6 +616,37 @@ public function test_login_skips_scripts_printed_in_footer(): void { $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); } + /** + * Tests that the login screen is recognized by the request rather than by the hook, so that the + * prefetching still works when it is moved to another hook, such as back to the head. + * + * @ticket 57548 + */ + public function test_login_prefetches_from_another_hook(): void { + $this->go_to_login_screen(); + + remove_all_actions( 'login_head' ); + add_action( 'login_head', 'wp_prefetch_admin_assets' ); + + $links = $this->parse_prefetch_links( get_echo( 'do_action', array( 'login_head' ) ) ); + + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); + } + + /** + * Tests that nothing is printed, and no error raised, on a request that is neither for the login + * screen nor for an admin screen, such as one for the front end, where get_current_screen() is + * not defined unless the admin includes have been loaded. + * + * @ticket 57548 + */ + public function test_prints_nothing_on_front_end(): void { + $this->assertFalse( is_login(), 'Expected the request not to be for the login screen.' ); + $this->assertNull( $GLOBALS['current_screen'] ?? null, 'Expected no current screen.' ); + + $this->assertSame( '', get_echo( 'wp_prefetch_admin_assets' ) ); + } + /** * Tests that the prefetching from the login screen is hooked to run after its footer scripts. * @@ -772,32 +845,23 @@ 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 string|null $action Action as resolved by wp-login.php, or null for - * `login_footer` fired by a plugin outside of it. - * @param bool $interim_login Whether wp-login.php is displaying the interim - * login modal. + * @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(), ?string $action = 'login', bool $interim_login = false ): array { - return $this->parse_prefetch_links( $this->get_login_footer_output( $request, $action, $interim_login ) ); + private function get_prefetched_on_login( array $request = array() ): array { + return $this->parse_prefetch_links( $this->get_login_footer_output( $request ) ); } /** * Runs the login screen's prefetching and returns everything it printed. * - * @param array $request Request parameters of the login screen. - * @param string|null $action Action as resolved by wp-login.php, or null for - * `login_footer` fired by a plugin outside of it. - * @param bool $interim_login Whether wp-login.php is displaying the interim - * login modal. + * @param array $request Request parameters of the login screen. * @return string Printed markup. */ - private function get_login_footer_output( array $request = array(), ?string $action = 'login', bool $interim_login = false ): string { + private function get_login_footer_output( array $request = array() ): string { $_REQUEST = $request; - $GLOBALS['action'] = $action; - $GLOBALS['interim_login'] = $interim_login; + $this->go_to_login_screen(); remove_all_actions( 'login_footer' ); add_action( 'login_footer', 'wp_prefetch_admin_assets' ); @@ -805,6 +869,14 @@ private function get_login_footer_output( array $request = array(), ?string $act return get_echo( 'do_action', array( 'login_footer' ) ); } + /** + * Makes the request one for the login screen. + */ + private function go_to_login_screen(): void { + $_SERVER['SCRIPT_NAME'] = (string) wp_parse_url( wp_login_url(), PHP_URL_PATH ); + $this->assertTrue( is_login(), 'Expected the request to be for the login screen.' ); + } + /** * Runs an admin screen's prefetching and returns the links it printed. * From 7105005ebc1509cc145167352b22395edaca3c67 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 22:52:46 -0700 Subject: [PATCH 49/71] Treat an empty redirect_to as none when prefetching from the login screen An empty `redirect_to` resolved to an empty next screen, which failed the check that the login lands in the admin, so nothing was prefetched. wp-login.php falls back to the admin for an empty `redirect_to`, so it now counts as none here too. The lost password form submits an empty one, for instance, so it was present whenever that form was shown again with an error. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 6 +++++- tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 369938757bdfd..ef223b90ccccb 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2644,10 +2644,14 @@ function wp_prefetch_admin_assets(): void { * not, it typically points back into wp-login.php, so nothing is prefetched. Nor does an * interim login need to be excluded: it shows inside a modal on an admin screen that has * already loaded these assets, so they are served from the HTTP cache. + * + * An empty `redirect_to` counts as none, since wp-login.php falls back to the admin for it + * too. The lost password form submits one, for instance, so it is present when the form is + * shown again with an error. */ $next_screen = admin_url(); - if ( isset( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) { + if ( ! empty( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) { $next_screen = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ), admin_url() ); } diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index b0bd46c24f8b2..9d897a9f2ab38 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -314,6 +314,13 @@ public function data_login_screens(): array { 'check email' => array( array( 'checkemail' => 'confirm' ) ), 'interim login' => array( array( 'interim-login' => '1' ) ), 'unrecognized action' => array( array( 'action' => 'unrecognized' ) ), + 'empty redirect' => array( array( 'redirect_to' => '' ) ), + 'lost password error' => array( + array( + 'action' => 'lostpassword', + 'redirect_to' => '', + ), + ), 'admin email reminder' => array( array( 'action' => 'confirm_admin_email', From 2fa816699b55226a808e0b528c236dbb5ae82ae8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 23:00:46 -0700 Subject: [PATCH 50/71] Skip what the current screen has queued, not only what it has printed Handles the current screen loads itself were only skipped once they were in `done`, so on the login screen jQuery stayed out of the prefetch only because it ran on `login_footer` after `wp_print_footer_scripts()`. A plugin that moved the footer scripts to a later priority, or a callback moved to an earlier hook, brought back the prefetch of jQuery that competes with the login screen's own request for it. The handles the current screen has queued are now skipped too, along with their dependencies, whether or not they have printed yet. The expansion of the queue uses the same loop as the expansion of the roots, which leaves the dependencies' state untouched, unlike `WP_Dependencies::all_deps()`. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/default-filters.php | 2 +- src/wp-includes/script-loader.php | 69 +++++++++++-------- .../dependencies/wpPrefetchAdminAssets.php | 21 ++++++ 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 4c8a67e9dd955..7e0d4dbc80dde 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -398,7 +398,7 @@ add_action( 'login_head', 'print_admin_styles', 9 ); add_action( 'login_head', 'wp_site_icon', 99 ); add_action( 'login_footer', 'wp_print_footer_scripts', 20 ); -add_action( 'login_footer', 'wp_prefetch_admin_assets', 21 ); // After footer scripts, so those the login screen loads itself are skipped. +add_action( 'login_footer', 'wp_prefetch_admin_assets', 21 ); // By the footer, the login screen has enqueued everything it loads, so those are skipped. add_action( 'login_init', 'send_frame_options_header', 10, 0 ); add_action( 'login_init', 'wp_admin_headers' ); diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index ef223b90ccccb..58297e00310d1 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2543,13 +2543,11 @@ function wp_should_concatenate_admin_scripts(): bool { * 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. On the login screen this runs in the footer, after the footer scripts, since - * scripts the login form enqueues after its header has printed, such as `user-profile`, bring in - * some of the same handles, like `jquery`. A prefetch of those in the head would compete with the - * login screen's own requests for them. On admin screens it runs in the head, where only the - * editor's stylesheets are prefetched, and a screen prints nearly all of its own stylesheets in - * the head as well. + * Handles the current screen has printed or queued are skipped, along with their dependencies, so + * each context only fetches what it is actually adding. On the login screen this runs in the + * footer, since some of what the login form loads, such as `user-profile` and the `jquery` it + * depends on, is only enqueued after its header has printed. On admin screens it runs in the head, + * where the screen's assets have been enqueued already. * * These are resources for the *next* navigation rather than for the screen printing them, which is * what `rel="prefetch"` describes. `rel="preload"` would fetch them at the current document's @@ -2815,37 +2813,54 @@ function wp_prefetch_admin_assets(): void { 'script' => array( wp_scripts(), $script_roots ), 'style' => array( wp_styles(), $style_roots ), ) - as $as => list( $dependencies, $queue ) + as $as => list( $dependencies, $roots ) ) { /* - * Expand the roots to include everything they depend on, roots first. A handle that is not - * registered is dropped along with its dependencies. + * Expand the roots to include everything they depend on, roots first, and likewise what the + * current screen has queued. A handle that is not registered is dropped along with its + * dependencies. Unlike WP_Dependencies::all_deps(), this leaves the dependencies' state + * untouched. */ - $handles = array(); + $expanded = array( + 'roots' => array(), + 'queue' => array(), + ); - while ( $queue ) { - $handle = array_shift( $queue ); + foreach ( + array( + 'roots' => $roots, + 'queue' => $dependencies->queue, + ) + as $list => $handles + ) { + while ( $handles ) { + $handle = array_shift( $handles ); - if ( isset( $handles[ $handle ] ) || ! isset( $dependencies->registered[ $handle ] ) ) { - continue; - } + if ( isset( $expanded[ $list ][ $handle ] ) || ! isset( $dependencies->registered[ $handle ] ) ) { + continue; + } - $handles[ $handle ] = true; + $expanded[ $list ][ $handle ] = true; - foreach ( $dependencies->registered[ $handle ]->deps as $dependency ) { - if ( is_string( $dependency ) && '' !== $dependency && ! isset( $handles[ $dependency ] ) ) { - $queue[] = $dependency; + foreach ( $dependencies->registered[ $handle ]->deps as $dependency ) { + if ( is_string( $dependency ) && '' !== $dependency && ! isset( $expanded[ $list ][ $dependency ] ) ) { + $handles[] = $dependency; + } } } } - foreach ( array_keys( $handles ) as $handle ) { - /* - * Whichever screen this is running on shares some of these handles and has already - * printed them by the time this runs, so the browser is fetching them anyway. - * Prefetching them again would only add markup. - */ - if ( in_array( $handle, $dependencies->done, true ) ) { + /* + * Whichever screen this is running on shares some of these handles, and the browser fetches + * those for it anyway, so prefetching them as well would only compete with its own requests. + * These are the ones it has printed, and the ones it has queued along with their + * dependencies, which it has yet to print if this runs before its footer scripts. On the + * login screen, for instance, `user-profile` brings in `jquery`. + */ + $on_current_screen = $expanded['queue'] + array_fill_keys( $dependencies->done, true ); + + foreach ( array_keys( $expanded['roots'] ) as $handle ) { + if ( isset( $on_current_screen[ $handle ] ) ) { continue; } diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 9d897a9f2ab38..96611b015dfdb 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -594,6 +594,27 @@ public function test_skips_assets_already_printed(): void { $this->assertPrefetched( $links, 'style', '#/wp-admin/css/forms(\.min)?\.css#' ); } + /** + * Tests that assets the current screen has queued but not yet printed are not prefetched either, + * along with their dependencies, so the result does not depend on whether this runs before or + * after the screen's footer scripts. + * + * @ticket 57548 + */ + public function test_skips_assets_queued_but_not_yet_printed(): void { + wp_enqueue_script( 'user-profile' ); + wp_enqueue_style( 'forms' ); + + $links = $this->get_prefetched_on_login(); + + $this->assertSame( array(), wp_scripts()->done, 'Expected no scripts to have been printed.' ); + $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery(\.min)?\.js#' ); + $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery-migrate(\.min)?\.js#' ); + $this->assertNotPrefetched( $links, '#/wp-admin/css/forms(\.min)?\.css#' ); + $this->assertPrefetched( $links, 'script', '#/wp-includes/js/utils(\.min)?\.js#' ); + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); + } + /** * Tests that the login screen skips scripts it loads itself in the footer. * From 9f0261a603030909a157530f87cd134142bc4901 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 23:05:48 -0700 Subject: [PATCH 51/71] Test the login screen's prefetching with the hooks default-filters.php registers The test of the login screen skipping its own footer scripts removed every `login_footer` callback and added back `wp_print_footer_scripts()` and `wp_prefetch_admin_assets()` at priorities of its own, so it tested a setup it had built rather than the one that ships. It now fires `login_footer` with the default callbacks, and checks that the prefetch links follow the footer scripts in the output, which fails if the prefetching is registered ahead of them. That covers what the separate test of the two priorities checked, so that test is folded into it. Co-Authored-By: Claude Opus 5.5 --- .../dependencies/wpPrefetchAdminAssets.php | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 96611b015dfdb..7171cde26a310 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -619,25 +619,27 @@ public function test_skips_assets_queued_but_not_yet_printed(): void { * Tests that the login screen skips scripts it loads itself in the footer. * * wp-login.php enqueues `user-profile` after its header has printed, and that script brings in - * `jquery`, so the login screen loads jQuery in its footer. The prefetching runs after the - * footer scripts, as registered in default-filters.php, so it leaves jQuery out but still - * prefetches `utils`, which the login screen does not load. + * `jquery`, so the login screen loads jQuery in its footer. With the callbacks registered in + * default-filters.php, the prefetching runs in the footer, after the footer scripts, so it + * leaves jQuery out but still prefetches `utils`, which the login screen does not load. * * @ticket 57548 */ public function test_login_skips_scripts_printed_in_footer(): void { - $this->go_to_login_screen(); - - remove_all_actions( 'login_footer' ); - add_action( 'login_footer', 'wp_print_footer_scripts', 20 ); - add_action( 'login_footer', 'wp_prefetch_admin_assets', 21 ); + $this->assertFalse( has_action( 'login_head', 'wp_prefetch_admin_assets' ), 'Expected no prefetching from the head of the login screen.' ); + $this->assertIsInt( has_action( 'login_footer', 'wp_prefetch_admin_assets' ), 'Expected prefetching from the footer of the login screen.' ); + $this->go_to_login_screen(); wp_enqueue_script( 'user-profile' ); $output = get_echo( 'do_action', array( 'login_footer' ) ); $links = $this->parse_prefetch_links( $output ); - $this->assertMatchesRegularExpression( '#]+src=[\'"][^\'"]*/wp-includes/js/jquery/jquery(\.min)?\.js#', $output, 'Expected the login screen to load jQuery itself.' ); + $jquery_position = strpos( $output, '/wp-includes/js/jquery/jquery' ); + $this->assertIsInt( $jquery_position, 'Expected the login screen to load jQuery itself.' ); + $this->assertMatchesRegularExpression( '#]+src=[\'"][^\'"]*/wp-includes/js/jquery/jquery(\.min)?\.js#', $output, 'Expected jQuery to be loaded with a script tag.' ); + $this->assertGreaterThan( $jquery_position, strpos( $output, "rel='prefetch'" ), 'Expected the prefetch links to follow the footer scripts.' ); + $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery(\.min)?\.js#' ); $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery-migrate(\.min)?\.js#' ); $this->assertPrefetched( $links, 'script', '#/wp-includes/js/utils(\.min)?\.js#' ); @@ -675,23 +677,6 @@ public function test_prints_nothing_on_front_end(): void { $this->assertSame( '', get_echo( 'wp_prefetch_admin_assets' ) ); } - /** - * Tests that the prefetching from the login screen is hooked to run after its footer scripts. - * - * @ticket 57548 - * - * @coversNothing - */ - public function test_login_hook_follows_footer_scripts(): void { - $prefetch_priority = has_action( 'login_footer', 'wp_prefetch_admin_assets' ); - $scripts_priority = has_action( 'login_footer', 'wp_print_footer_scripts' ); - - $this->assertIsInt( $prefetch_priority ); - $this->assertIsInt( $scripts_priority ); - $this->assertGreaterThan( $scripts_priority, $prefetch_priority ); - $this->assertFalse( has_action( 'login_head', 'wp_prefetch_admin_assets' ) ); - } - /** * Tests that a right-to-left locale prefetches the right-to-left stylesheets. * From ff4c4d1768e8b5c77e2f6d0e1ba4bde8499939db Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 23:19:07 -0700 Subject: [PATCH 52/71] Check the block editor for the editor's post type in one place Whether the next screen uses the block editor was checked twice: by the admin branch for the post type of the list table, and by a check limited to the login screen for the post type in `redirect_to`. The admin branch already builds the next screen as post-new.php with its post type in the query, so the login screen's check now runs for both contexts, and the admin branch only checks the user's capability. When the post type uses the classic editor, an admin screen is left with nothing to prefetch, so it now returns once it finds no roots to expand, before the `prefetch_admin_assets` filter, as it did before. The test for that case checks that the filter is not applied. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 25 ++++++++++--------- .../dependencies/wpPrefetchAdminAssets.php | 7 +++++- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 58297e00310d1..bccc26b8db4ac 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2697,16 +2697,12 @@ function wp_prefetch_admin_assets(): void { } /* - * A user who cannot edit posts of this type will never reach the editor from here, and a - * post type still using the classic editor would not load any of these stylesheets. This is + * A user who cannot edit posts of this type will never reach the editor from here. This is * `edit_posts` rather than `create_posts`, since the editor is reached by opening an existing * post as well as by adding a new one, and a user may be able to do the first but not the * second. Adding a new one requires `edit_posts` too. */ - if ( - ! current_user_can( $post_type_object->cap->edit_posts ) || - ! use_block_editor_for_post_type( $post_type ) - ) { + if ( ! current_user_can( $post_type_object->cap->edit_posts ) ) { return; } @@ -2766,14 +2762,14 @@ function wp_prefetch_admin_assets(): void { $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 && $on_login ) { + if ( $next_screen_is_block_editor ) { /* * The editor only loads these stylesheets when the post type uses the block editor, which - * the classic editor, for instance, can turn off. The admin screens have checked this - * already. From the login screen, the post type is the one in `redirect_to`, as for - * post-new.php. An edit link to post.php does not carry one, so it is taken to be a post: - * looking the post up instead would let anyone tell from the login screen whether a post - * with a given ID exists, drafts and private posts included. + * the classic editor, for instance, can turn off. The post type is the one in the query of + * the next screen, as for post-new.php. An edit link to post.php from the login screen does + * not carry one, so it is taken to be a post: looking the post up instead would let anyone + * tell from the login screen whether a post with a given ID exists, drafts and private posts + * included. */ $next_screen_post_type = $next_screen_query['post_type'] ?? 'post'; $next_screen_is_block_editor = is_string( $next_screen_post_type ) && use_block_editor_for_post_type( $next_screen_post_type ); @@ -2806,6 +2802,11 @@ function wp_prefetch_admin_assets(): void { ); } + // From an admin screen, nothing is left to prefetch when the editor turns out not to be the block editor. + if ( ! $script_roots && ! $style_roots ) { + return; + } + $resources = array(); foreach ( diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 7171cde26a310..b591dbd5e9f62 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -567,7 +567,8 @@ public function test_admin_screen_prefetches_for_user_who_can_edit_but_not_creat } /** - * Tests that nothing is prefetched for a post type that uses the classic editor. + * Tests that nothing is prefetched for a post type that uses the classic editor, and that the + * filter is not applied either, since there is no destination to prefetch for. * * @ticket 57548 */ @@ -575,7 +576,11 @@ public function test_admin_screen_prints_nothing_for_classic_editor(): void { wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); add_filter( 'use_block_editor_for_post_type', '__return_false' ); + $filter = new MockAction(); + add_filter( 'prefetch_admin_assets', array( $filter, 'filter' ) ); + $this->assertSame( array(), $this->get_prefetched_on_admin_screen( 'edit' ) ); + $this->assertSame( 0, $filter->get_call_count(), 'Expected the filter not to be applied.' ); } /** From 20e51e3b81e2b04ebfb55e317530977ea505982b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 23:33:59 -0700 Subject: [PATCH 53/71] Skip expanding the current screen's queue for a type with no roots The current screen's queue was expanded for scripts and styles alike, to find what it loads itself, even when there was nothing of that type to prefetch. The editor's prefetch from the Dashboard and the post list tables has no scripts, so every queued admin script and its dependencies were walked on each of those screens for a map that was never read. A type with no roots is now skipped. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index bccc26b8db4ac..e2e91811f6249 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2816,6 +2816,11 @@ function wp_prefetch_admin_assets(): void { ) as $as => list( $dependencies, $roots ) ) { + // Skip a type with nothing to prefetch, such as scripts for the editor, rather than expanding the current screen's queue of it. + if ( ! $roots ) { + continue; + } + /* * Expand the roots to include everything they depend on, roots first, and likewise what the * current screen has queued. A handle that is not registered is dropped along with its From 3e9ae031a130b10be2beed31978340d85ce06042 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 23:41:29 -0700 Subject: [PATCH 54/71] Call admin_url() once when resolving the login screen's destination The login screen called `admin_url()` six times while resolving where the login lands and comparing it with the admin, each call running the `site_url` and `admin_url` filters. It is now called once and the result reused, which also makes it plain that every comparison is against the same URL. Co-Authored-By: Claude Opus 5.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 e2e91811f6249..ab7b1db668e46 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2647,17 +2647,18 @@ function wp_prefetch_admin_assets(): void { * too. The lost password form submits one, for instance, so it is present when the form is * shown again with an error. */ - $next_screen = admin_url(); + $admin_url = admin_url(); + $next_screen = $admin_url; if ( ! empty( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) { - $next_screen = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ), admin_url() ); + $next_screen = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ), $admin_url ); } /* * When the login lands somewhere other than the admin, such as the front end or a plugin's * own screen, none of these assets are wanted. */ - $admin_path = (string) wp_parse_url( admin_url(), PHP_URL_PATH ); + $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; @@ -2678,11 +2679,11 @@ function wp_prefetch_admin_assets(): void { 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 ) || + 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 ) || ( is_string( $next_screen_scheme ) && - strtolower( $next_screen_scheme ) !== strtolower( (string) wp_parse_url( admin_url(), PHP_URL_SCHEME ) ) + strtolower( $next_screen_scheme ) !== strtolower( (string) wp_parse_url( $admin_url, PHP_URL_SCHEME ) ) ) ) ) { From 49606b57fde1ed3177b7d2350e521d2ea7e83b79 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 2 Oct 2026 23:47:07 -0700 Subject: [PATCH 55/71] Document when to add a wp_should_concatenate_admin_scripts callback, and that it is temporary An admin screen settles the `$concatenate_scripts` global the first time scripts are registered, when the `wp_default_scripts` action registers TinyMCE, which can be while plugins are still loading. The login screen reads the `wp_should_concatenate_admin_scripts` filter only when it prints. A callback added from a theme, or on a later hook, could therefore be missed by the admin but seen by the login screen, which would then predict the wrong concatenation setting. The filter's docblock now says to add a callback when a plugin loads. The function's docblock also now says that the function and its filter are intended to be removed before 7.2-beta1, since they exist only while concatenation is still an option, and that the `$concatenate_scripts` global remains the way to override it. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index ab7b1db668e46..ce0d0f8436e7e 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2505,6 +2505,11 @@ function script_concat_settings() { * the global has not already been set. It is also how wp_prefetch_admin_assets() predicts, from the * login screen, what the admin screen the login leads to will do. * + * This function and its filter are intended to be removed before 7.2-beta1. They exist only while + * concatenation is still an option, and once concatenation is retired, there is nothing left for + * them to decide. Do not rely on them; setting the `$concatenate_scripts` global remains the way + * to override concatenation on a request. + * * @since 7.2.0 * * @return bool Whether scripts and styles are concatenated on admin screens and the login screen. @@ -2519,6 +2524,12 @@ function wp_should_concatenate_admin_scripts(): bool { * Setting the `$concatenate_scripts` global directly still takes precedence over this filter on * the request where it is set. * + * An admin screen settles the global the first time scripts are registered, when the + * {@see 'wp_default_scripts'} action registers TinyMCE. That can be as early as while plugins + * load, before the theme's functions.php, whereas the login screen reads this filter only when + * it prints, to predict what the admin will do. So add a callback when a plugin loads, rather + * than from a theme or on a later hook, or the admin may not see it while the login screen does. + * * @since 7.2.0 * * @param bool $concatenate Whether scripts and styles are concatenated. Default true, unless the From 355774c0007bcb3f25c034a475f1964c48874baf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Oct 2026 09:39:21 -0700 Subject: [PATCH 56/71] Prefetch for a redirect_to of the admin without its trailing slash The login screen checks that `redirect_to` lands in the admin by testing whether its path starts with the admin's, which ends in a slash. A `redirect_to` of the admin itself without that slash, such as `/wp-admin`, failed the check and printed nothing, although the login does land in the admin. The destination's path now gets a trailing slash before the comparison. A lookalike path such as `/wp-admin-lookalike/` still fails it. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 5 +++-- .../tests/dependencies/wpPrefetchAdminAssets.php | 13 +++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index ce0d0f8436e7e..59792723119c5 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2667,11 +2667,12 @@ function wp_prefetch_admin_assets(): void { /* * 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. + * own screen, none of these assets are wanted. The admin's path ends in a slash, which a + * `redirect_to` of the admin itself may leave off, as in `/wp-admin`. */ $admin_path = (string) wp_parse_url( $admin_url, PHP_URL_PATH ); - if ( '' === $admin_path || ! str_starts_with( (string) wp_parse_url( $next_screen, PHP_URL_PATH ), $admin_path ) ) { + if ( '' === $admin_path || ! str_starts_with( trailingslashit( (string) wp_parse_url( $next_screen, PHP_URL_PATH ) ), $admin_path ) ) { return; } diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index b591dbd5e9f62..ded638e215df8 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -190,12 +190,13 @@ 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 ), + 'Dashboard' => array( '/wp-admin/', false ), + 'Dashboard, no trailing slash' => array( '/wp-admin', false ), + 'new post' => array( '/wp-admin/post-new.php', true ), + 'new page' => array( '/wp-admin/post-new.php?post_type=page', true ), + 'editing a post' => array( '/wp-admin/post.php?post=1&action=edit', true ), + 'trashing a post' => array( '/wp-admin/post.php?post=1&action=trash', false ), + 'post list' => array( '/wp-admin/edit.php', false ), ); } From 578f84ecd96d2002e3794e78672aac39de6516e1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Oct 2026 09:47:25 -0700 Subject: [PATCH 57/71] Return the new get_src() and get_rtl_src() URLs unsanitized `WP_Scripts::get_src()`, `WP_Styles::get_src()` and `WP_Styles::get_rtl_src()` passed their URLs through `esc_url_raw()`, and the prefetching then escaped them with `esc_url()` to print them, so the `clean_url` filter ran twice for each prefetched URL, in the `db` context and then in `display`. A callback that depends on the context, or does not give the same result when run twice, could make a prefetched URL differ from the one the next screen requests, where the filter runs once. The three methods now return the filtered URL unsanitized, as `WP_Script_Modules::get_src()` already does, and each caller sanitizes or escapes it once: `WP_Scripts::do_item()` with `esc_url_raw()` and `WP_Styles::do_item()` with `esc_url()`, as before they called these methods, and the prefetching with `esc_url()`. `WP_Styles::do_item()` can therefore use `get_rtl_src()` again, and the private `build_rtl_src()` that it used to avoid the second pass is gone. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/class-wp-scripts.php | 6 ++-- src/wp-includes/class-wp-styles.php | 29 +++++-------------- .../dependencies/wpPrefetchAdminAssets.php | 28 ++++++++++++++++++ 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index c7d26e799c828..9104a815314ad 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -415,7 +415,7 @@ public function do_item( $handle, $group = false ) { return true; } - $src = $this->get_src( $handle ); + $src = esc_url_raw( $this->get_src( $handle ) ); if ( ! $src ) { return true; @@ -479,6 +479,8 @@ public function do_item( $handle, $group = false ) { * * This is the URL printed in the script's `src` attribute, including the version query * argument and any arguments added to the handle, after the {@see 'script_loader_src'} filter. + * Like {@see WP_Script_Modules::get_src()}, it is neither sanitized nor escaped, so a caller + * can pass it through esc_url_raw(), as WP_Scripts::do_item() does, or esc_url(), once. * * @since 7.2.0 * @@ -532,7 +534,7 @@ public function get_src( string $handle ): string { /** 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 ) : ''; + return is_string( $src ) ? $src : ''; } /** diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index fe2e62dea23a7..be9db97c520db 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -224,8 +224,7 @@ public function do_item( $handle, $group = false ) { */ $tag = apply_filters( 'style_loader_tag', $tag, $handle, $href, $media ); - // Not get_rtl_src(), which would sanitize the URL before it is escaped here, running the 'clean_url' filter twice. - $rtl_src = $this->build_rtl_src( $handle ); + $rtl_src = $this->get_rtl_src( $handle ); if ( null !== $rtl_src ) { $rtl_href = esc_url( $rtl_src ); @@ -268,7 +267,8 @@ public function do_item( $handle, $group = false ) { * stylesheet, which either replaces its left-to-right one (when the data is `'replace'`) * or loads alongside it. * - * Like {@see WP_Styles::get_src()}, the URL is not escaped for an HTML attribute. + * Like {@see WP_Styles::get_src()}, the URL is neither sanitized nor escaped, so a caller can + * pass it through esc_url() to print it, or esc_url_raw() otherwise, once. * * @since 7.2.0 * @@ -278,23 +278,6 @@ public function do_item( $handle, $group = false ) { * not registered, has no source of its own, or has no right-to-left variant. */ public function get_rtl_src( string $handle ): ?string { - $src = $this->build_rtl_src( $handle ); - - return null === $src ? null : esc_url_raw( $src ); - } - - /** - * Builds the URL of the right-to-left stylesheet for a registered style. - * - * The result is not sanitized or escaped, so callers can do either once, for where it is used. - * - * @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, or null when there is none. See {@see WP_Styles::get_rtl_src()}. - */ - private function build_rtl_src( string $handle ): ?string { if ( 'rtl' !== $this->text_direction || ! isset( $this->registered[ $handle ] ) ) { return null; } @@ -477,7 +460,9 @@ public function _css_href( $src, $ver, $handle ) { * * This is the URL printed in the stylesheet's `href` attribute, including the version query * argument and any arguments added to the handle, after the {@see 'style_loader_src'} filter. - * Unlike {@see WP_Styles::_css_href()}, it is not escaped for an HTML attribute. + * Unlike {@see WP_Styles::_css_href()}, it is neither sanitized nor escaped, so a caller can + * pass it through esc_url() to print it, or esc_url_raw() otherwise, once. This is the same as + * {@see WP_Script_Modules::get_src()}. * * @since 7.2.0 * @@ -497,7 +482,7 @@ public function get_src( string $handle ): string { return ''; } - return esc_url_raw( $this->build_src( $obj->src, $obj->ver, $handle ) ); + return $this->build_src( $obj->src, $obj->ver, $handle ); } /** diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index ded638e215df8..64a18fac41c54 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -697,6 +697,34 @@ public function test_prefetches_rtl_stylesheets(): void { $this->assertNotPrefetched( $links, '#/wp-admin/css/common(\.min)?\.css#' ); } + /** + * Tests that each prefetched URL is sanitized only once, so that the {@see 'clean_url'} filter + * runs once for it, as it does for the URL in the tag the next screen prints. + * + * @ticket 57548 + */ + public function test_prefetched_urls_run_clean_url_filter_once(): void { + // The contexts the filter ran in, keyed by the URL it was given, before it was escaped. + $calls = array(); + add_filter( + 'clean_url', + static function ( string $good_protocol_url, string $original_url, string $context ) use ( &$calls ): string { + $calls[ $original_url ][] = $context; + return $good_protocol_url; + }, + 10, + 3 + ); + + $links = $this->get_prefetched_on_login(); + $this->assertNotSame( array(), $links ); + + foreach ( $links as $link ) { + $href = html_entity_decode( $link['href'], ENT_QUOTES ); + $this->assertSame( array( 'display' ), $calls[ $href ] ?? array(), "Expected the 'clean_url' filter to run once for {$href}." ); + } + } + /** * Tests that stylesheet URLs reach the filter unescaped, like script URLs, so that a callback * appending the plain form of one is collapsed with it. From 7257f451fc0820c9fb6d84de20c05aff8a5745bf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Oct 2026 09:54:00 -0700 Subject: [PATCH 58/71] Skip prefetching handles the next screen would print nothing for Two kinds of handle were prefetched although the next screen's `do_item()` prints nothing for them. A handle with conditional data makes `do_item()` return early. A style whose own URL is filtered away makes `WP_Styles::do_item()` return before its right-to-left stylesheet too, yet in a right-to-left locale that stylesheet was still prefetched when it replaces the left-to-right one. Both are now skipped. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 17 +++++++- .../dependencies/wpPrefetchAdminAssets.php | 41 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 59792723119c5..63d4ca89e8a4f 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2883,12 +2883,27 @@ function wp_prefetch_admin_assets(): void { continue; } + // The next screen prints nothing for a handle with conditional data, as do_item() returns early for it. + if ( $dependencies->registered[ $handle ]->extra['conditional'] ?? false ) { + continue; + } + /* * The URLs are built the same way WP_Scripts::do_item() and WP_Styles::do_item() build * those in the tags they print, so they match what the next screen will request. They are * not escaped yet, so the filter sees plain URLs; they are escaped when printed. + * + * A handle with no URL of its own, such as an alias or one whose URL was filtered away, + * prints nothing either, and for a style that includes its right-to-left stylesheet, + * since WP_Styles::do_item() returns before getting to it. */ - $urls = array( $dependencies->get_src( $handle ) ); + $src = $dependencies->get_src( $handle ); + + if ( '' === $src ) { + continue; + } + + $urls = array( $src ); if ( $dependencies instanceof WP_Styles ) { $rtl_src = $dependencies->get_rtl_src( $handle ); diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 64a18fac41c54..2da5571720ced 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -697,6 +697,47 @@ public function test_prefetches_rtl_stylesheets(): void { $this->assertNotPrefetched( $links, '#/wp-admin/css/common(\.min)?\.css#' ); } + /** + * Tests that a handle with conditional data is not prefetched, since do_item() prints nothing + * for it. + * + * @ticket 57548 + * + * @expectedDeprecated WP_Dependencies->add_data() + */ + public function test_skips_conditional_handles(): void { + wp_styles()->add_data( 'forms', 'conditional', 'IE' ); + + $links = $this->get_prefetched_on_login(); + + $this->assertNotPrefetched( $links, '#/wp-admin/css/forms(\.min)?\.css#' ); + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); + } + + /** + * Tests that a style whose own URL is filtered away is not prefetched in a right-to-left locale + * either, since WP_Styles::do_item() returns before printing its right-to-left stylesheet. + * + * @ticket 57548 + */ + public function test_skips_rtl_stylesheet_of_style_filtered_away(): void { + wp_styles()->text_direction = 'rtl'; + + add_filter( + 'style_loader_src', + static function ( $src, string $handle ) { + return 'common' === $handle ? '' : $src; + }, + 10, + 2 + ); + + $links = $this->get_prefetched_on_login(); + + $this->assertNotPrefetched( $links, '#/wp-admin/css/common(-rtl)?(\.min)?\.css#' ); + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/forms-rtl(\.min)?\.css#' ); + } + /** * Tests that each prefetched URL is sanitized only once, so that the {@see 'clean_url'} filter * runs once for it, as it does for the URL in the tag the next screen prints. From 01353f4c2eef825e18e729e2f15e45c630b510f7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Oct 2026 09:54:53 -0700 Subject: [PATCH 59/71] Name WP_Dependencies::add_data() with :: in its deprecation notice The notice for passing `conditional` to `add_data()` named the method `WP_Dependencies->add_data()`, as if `WP_Dependencies` were a variable holding an instance. Every other method named in core's `_deprecated_*()` and `_doing_it_wrong()` calls uses the `Class::method()` form, as does the `@see` tag on `wp_script_add_data()` and `wp_style_add_data()`, so this one now does too. The `@expectedDeprecated` annotations of the tests that add conditional data are updated to match. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/class-wp-dependencies.php | 2 +- tests/phpunit/tests/dependencies/scripts.php | 6 +++--- tests/phpunit/tests/dependencies/styles.php | 2 +- tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/class-wp-dependencies.php b/src/wp-includes/class-wp-dependencies.php index 80559201dda03..8f87607c1565a 100644 --- a/src/wp-includes/class-wp-dependencies.php +++ b/src/wp-includes/class-wp-dependencies.php @@ -316,7 +316,7 @@ public function add_data( $handle, $key, $value ) { } if ( 'conditional' === $key && '_required-conditional-dependency_' !== $value ) { _deprecated_argument( - 'WP_Dependencies->add_data()', + 'WP_Dependencies::add_data()', '6.9.0', __( 'IE conditional comments are ignored by all supported browsers.' ) ); diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index adb8f2d3d04a0..000838f0fbd0c 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -2350,7 +2350,7 @@ public function test_wp_script_add_data_with_data_key() { /** * Testing `wp_script_add_data` with the conditional key. * - * @expectedDeprecated WP_Dependencies->add_data() + * @expectedDeprecated WP_Dependencies::add_data() * * @since 6.9.0 Conditional comments should now return an empty string. * @@ -2770,7 +2770,7 @@ public function test_wp_add_inline_script_after_with_concat() { } /** - * @expectedDeprecated WP_Dependencies->add_data() + * @expectedDeprecated WP_Dependencies::add_data() * * @ticket 14853 * @ticket 63821 @@ -2821,7 +2821,7 @@ public function test_wp_add_inline_script_after_with_concat_and_core_dependency( } /** - * @expectedDeprecated WP_Dependencies->add_data() + * @expectedDeprecated WP_Dependencies::add_data() * * @ticket 36392 * @ticket 63821 diff --git a/tests/phpunit/tests/dependencies/styles.php b/tests/phpunit/tests/dependencies/styles.php index 626a5861eb782..6f394d8c78697 100644 --- a/tests/phpunit/tests/dependencies/styles.php +++ b/tests/phpunit/tests/dependencies/styles.php @@ -364,7 +364,7 @@ public function test_unnecessary_style_tags() { * Test to make sure that inline styles attached to conditional * stylesheets are also conditional. * - * @expectedDeprecated WP_Dependencies->add_data() + * @expectedDeprecated WP_Dependencies::add_data() */ public function test_conditional_inline_styles_are_also_conditional() { wp_enqueue_style( 'handle', 'http://example.com', array(), 1 ); diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 2da5571720ced..5b376e56cbf06 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -703,7 +703,7 @@ public function test_prefetches_rtl_stylesheets(): void { * * @ticket 57548 * - * @expectedDeprecated WP_Dependencies->add_data() + * @expectedDeprecated WP_Dependencies::add_data() */ public function test_skips_conditional_handles(): void { wp_styles()->add_data( 'forms', 'conditional', 'IE' ); From d3ea12831e4d1e084a028f4267689954d5b9936a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Oct 2026 10:52:57 -0700 Subject: [PATCH 60/71] Recognize the login screen by 'login_init' rather than is_login() `is_login()` compares the login URL with the script handling the request. A plugin that serves the login screen at a URL of its own runs wp-login.php from another script, such as index.php, so `is_login()` was false there, and the login screen prefetched nothing. `did_action( 'login_init' )` holds however wp-login.php is reached, and it is also how `script_concat_settings()` recognizes the login screen. It still does not depend on which hook the function runs on. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 8 +++-- .../dependencies/wpPrefetchAdminAssets.php | 33 ++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 63d4ca89e8a4f..f8613c1549186 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2596,9 +2596,13 @@ function wp_prefetch_admin_assets(): void { /* * The context is told apart by the request rather than by the hook this runs on, so it works - * from whichever hook it is added to. + * from whichever hook it is added to. The 'login_init' action is fired by wp-login.php however + * it is reached, which is also how script_concat_settings() recognizes it. The is_login() + * function would not do: it compares the login URL with the script handling the request, and a + * plugin serving the login screen at a URL of its own runs wp-login.php from another script, + * such as index.php. */ - $on_login = is_login(); + $on_login = (bool) did_action( 'login_init' ); if ( $on_login ) { /* diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 5b376e56cbf06..357b9b8c1468f 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -669,6 +669,27 @@ public function test_login_prefetches_from_another_hook(): void { $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); } + /** + * Tests that the login screen is recognized when a plugin serves it at a URL of its own, running + * wp-login.php from another script, in which case is_login() does not recognize it. + * + * @ticket 57548 + */ + public function test_login_prefetches_at_custom_login_url(): void { + add_filter( + 'login_url', + static function (): string { + return home_url( '/my-login/' ); + } + ); + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $this->assertFalse( is_login(), 'Expected is_login() not to recognize the login screen.' ); + + $links = $this->get_prefetched_on_login(); + + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); + } + /** * Tests that nothing is printed, and no error raised, on a request that is neither for the login * screen nor for an admin screen, such as one for the front end, where get_current_screen() is @@ -677,7 +698,7 @@ public function test_login_prefetches_from_another_hook(): void { * @ticket 57548 */ public function test_prints_nothing_on_front_end(): void { - $this->assertFalse( is_login(), 'Expected the request not to be for the login screen.' ); + $this->assertSame( 0, did_action( 'login_init' ), 'Expected the request not to be for the login screen.' ); $this->assertNull( $GLOBALS['current_screen'] ?? null, 'Expected no current screen.' ); $this->assertSame( '', get_echo( 'wp_prefetch_admin_assets' ) ); @@ -958,11 +979,15 @@ private function get_login_footer_output( array $request = array() ): string { } /** - * Makes the request one for the login screen. + * Makes the request one for the login screen, by firing 'login_init' as wp-login.php does. + * + * Its callbacks are removed first, since one of them sends headers. */ private function go_to_login_screen(): void { - $_SERVER['SCRIPT_NAME'] = (string) wp_parse_url( wp_login_url(), PHP_URL_PATH ); - $this->assertTrue( is_login(), 'Expected the request to be for the login screen.' ); + remove_all_actions( 'login_init' ); + + /** This action is documented in wp-login.php */ + do_action( 'login_init' ); } /** From 96c9d804c85a161fd941267dad8db3a3bcddee51 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Oct 2026 11:03:59 -0700 Subject: [PATCH 61/71] Resolve redirect_to with wp_validate_redirect() alone, as wp_safe_redirect() does The login screen passed `redirect_to` through `esc_url_raw()` before `wp_validate_redirect()`. `esc_url_raw()` adds a scheme to a relative path, so `wp-admin/post-new.php` became `http://wp-admin/post-new.php`, whose host failed validation, and the editor's stylesheets were not prefetched although the login lands in the editor. A relative path such as `example.org/wp-admin/` went the other way: it was taken for this site's admin although the browser goes to `/example.org/wp-admin/`. `wp_safe_redirect()` sends the browser to what `wp_validate_redirect()` returns, and that already sanitizes the value and resolves a relative path against the current request. It is now used on its own, so the prediction matches where the login lands. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 9 ++++++--- .../phpunit/tests/dependencies/wpPrefetchAdminAssets.php | 9 ++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index f8613c1549186..a352422f448ef 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2645,8 +2645,11 @@ function wp_prefetch_admin_assets(): void { if ( $on_login ) { /* * Resolve where the login is going to land, the same way wp-login.php will: `redirect_to` - * when one was given, and the admin otherwise. wp_validate_redirect() mirrors what - * wp_safe_redirect() does with a value pointing off-host, which is to fall back to the admin. + * when one was given, and the admin otherwise. wp_safe_redirect() sends the browser to what + * wp_validate_redirect() returns, so that is used here as it is: it sanitizes the value, + * resolves a relative path against the current request, as the browser would, and falls back + * to the admin for a value pointing off-host. Passing the value through esc_url_raw() first + * would not match it, since that takes a relative path such as `wp-admin/post.php` for a host. * * This runs on every screen wp-login.php prints, not only the login form, since the others * mostly lead to the admin as well, and each one gives the prefetching another chance to @@ -2666,7 +2669,7 @@ function wp_prefetch_admin_assets(): void { $next_screen = $admin_url; if ( ! empty( $_REQUEST['redirect_to'] ) && is_string( $_REQUEST['redirect_to'] ) ) { - $next_screen = wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ), $admin_url ); + $next_screen = wp_validate_redirect( wp_unslash( $_REQUEST['redirect_to'] ), $admin_url ); } /* diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 357b9b8c1468f..8c06a5020a7af 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -197,6 +197,8 @@ public function data_editor_destinations(): array { 'editing a post' => array( '/wp-admin/post.php?post=1&action=edit', true ), 'trashing a post' => array( '/wp-admin/post.php?post=1&action=trash', false ), 'post list' => array( '/wp-admin/edit.php', false ), + 'relative, editing a post' => array( 'wp-admin/post.php?post=1&action=edit', true ), + 'relative, new post' => array( 'wp-admin/post-new.php', true ), ); } @@ -356,6 +358,8 @@ public function data_login_requests_not_leading_to_admin(): array { return array( 'front end redirect' => array( array( 'redirect_to' => '/hello-world/' ) ), 'lookalike admin path' => array( array( 'redirect_to' => '/wp-admin-lookalike/' ) ), + // A relative path, which the browser would resolve to /example.org/wp-admin/, not to this site's admin. + 'relative path resembling a host' => array( array( 'redirect_to' => 'example.org/wp-admin/' ) ), 'lost password redirecting to login' => array( array( 'action' => 'lostpassword', @@ -981,9 +985,12 @@ private function get_login_footer_output( array $request = array() ): string { /** * Makes the request one for the login screen, by firing 'login_init' as wp-login.php does. * - * Its callbacks are removed first, since one of them sends headers. + * Its callbacks are removed first, since one of them sends headers. The request URI is that of + * wp-login.php, which a relative `redirect_to` is resolved against. */ private function go_to_login_screen(): void { + $_SERVER['REQUEST_URI'] = (string) wp_parse_url( wp_login_url(), PHP_URL_PATH ); + remove_all_actions( 'login_init' ); /** This action is documented in wp-login.php */ From e4c4eb13cb50f3cbe2362133110fbb1f93f4624a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Oct 2026 11:08:46 -0700 Subject: [PATCH 62/71] Treat a redirect_to's explicit default port as the admin's own The login screen compared the ports of `redirect_to` and the admin URL as given, so `https://example.com:443/wp-admin/` was taken for an admin on another port than `https://example.com/wp-admin/`, and nothing was prefetched although the browser lands on the same admin. Each port now has the scheme's default filled in when none is given before they are compared. The comment above the check also said that a protocol-relative `redirect_to` keeps the login screen's scheme. Since `redirect_to` is resolved with `wp_validate_redirect()` alone, it gets the `http` scheme instead, which is where `wp_safe_redirect()` sends it, so the comment and the test for it now say so, and a test covers that nothing is prefetched for one from an `https` login screen. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 38 ++++++++++------- .../dependencies/wpPrefetchAdminAssets.php | 42 ++++++++++++++++++- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index a352422f448ef..6089879755cc1 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2689,24 +2689,32 @@ function wp_prefetch_admin_assets(): void { * admin would request its assets from its own host rather than from this one. The same goes * for this host's admin under another scheme, such as an `https` destination from an `http` * login: the URLs prefetched here take the scheme of the current request, so that admin - * would request different ones. A relative `redirect_to` stays on this host and scheme, and - * a protocol-relative one on this scheme. + * would request different ones. A relative `redirect_to` stays on this host and scheme, while + * wp_validate_redirect() gives a protocol-relative one the `http` scheme, which is where + * wp_safe_redirect() then sends it. A port is compared with the scheme's default filled in + * when none is given, since `https://example.com:443/` is the same admin as + * `https://example.com/`. */ - $next_screen_host = wp_parse_url( $next_screen, PHP_URL_HOST ); - $next_screen_scheme = wp_parse_url( $next_screen, PHP_URL_SCHEME ); + $next_screen_host = wp_parse_url( $next_screen, PHP_URL_HOST ); - if ( - is_string( $next_screen_host ) && - ( + if ( is_string( $next_screen_host ) ) { + $default_ports = array( + 'http' => 80, + 'https' => 443, + ); + + $admin_scheme = strtolower( (string) wp_parse_url( $admin_url, PHP_URL_SCHEME ) ); + $next_screen_scheme = strtolower( (string) wp_parse_url( $next_screen, PHP_URL_SCHEME ) ); + $admin_port = wp_parse_url( $admin_url, PHP_URL_PORT ) ?? $default_ports[ $admin_scheme ] ?? null; + $next_screen_port = wp_parse_url( $next_screen, PHP_URL_PORT ) ?? $default_ports[ $next_screen_scheme ] ?? null; + + if ( strtolower( $next_screen_host ) !== strtolower( (string) wp_parse_url( $admin_url, PHP_URL_HOST ) ) || - wp_parse_url( $next_screen, PHP_URL_PORT ) !== wp_parse_url( $admin_url, PHP_URL_PORT ) || - ( - is_string( $next_screen_scheme ) && - strtolower( $next_screen_scheme ) !== strtolower( (string) wp_parse_url( $admin_url, PHP_URL_SCHEME ) ) - ) - ) - ) { - return; + $next_screen_scheme !== $admin_scheme || + $next_screen_port !== $admin_port + ) { + return; + } } } else { $post_type = ( 'edit' === $current_screen->base && $current_screen->post_type ) ? $current_screen->post_type : 'post'; diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 8c06a5020a7af..25869744eae36 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -460,12 +460,34 @@ public function test_login_prints_nothing_for_admin_on_another_scheme(): void { } /** - * Tests that a protocol-relative `redirect_to` pointing at this site's admin prefetches, since it - * keeps the scheme of the login screen. + * Tests that an explicit port that is the scheme's default counts as the admin's own, since + * `http://example.org:80/` is the same admin as `http://example.org/`. + * + * @ticket 57548 + */ + public function test_login_prefetches_for_admin_with_explicit_default_port(): void { + $scheme = (string) wp_parse_url( admin_url(), PHP_URL_SCHEME ); + $host = (string) wp_parse_url( admin_url(), PHP_URL_HOST ); + $path = (string) wp_parse_url( admin_url( 'post-new.php' ), PHP_URL_PATH ); + $this->assertNull( wp_parse_url( admin_url(), PHP_URL_PORT ), 'Expected the admin URL to have no port.' ); + + $default_port = 'https' === $scheme ? 443 : 80; + + $links = $this->get_prefetched_on_login( array( 'redirect_to' => "{$scheme}://{$host}:{$default_port}{$path}" ) ); + + $this->assertPrefetched( $links, 'style', '#/wp-admin/css/common(\.min)?\.css#' ); + $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/edit-post/style(\.min)?\.css#' ); + } + + /** + * Tests that a protocol-relative `redirect_to` pointing at this site's admin prefetches when the + * admin is served over `http`, since wp_validate_redirect() gives such a value the `http` scheme. * * @ticket 57548 */ public function test_login_prefetches_for_protocol_relative_admin_url(): void { + $this->assertSame( 'http', wp_parse_url( admin_url(), PHP_URL_SCHEME ), 'Expected the admin to be served over http.' ); + $redirect_to = (string) preg_replace( '#^https?:#', '', admin_url( 'post-new.php' ) ); $this->assertStringStartsWith( '//', $redirect_to ); @@ -475,6 +497,22 @@ public function test_login_prefetches_for_protocol_relative_admin_url(): void { $this->assertPrefetched( $links, 'style', '#/wp-includes/css/dist/edit-post/style(\.min)?\.css#' ); } + /** + * Tests that nothing is prefetched from an `https` login screen for a protocol-relative + * `redirect_to`, since wp_validate_redirect() gives it the `http` scheme, which is where + * wp_safe_redirect() then sends the browser. + * + * @ticket 57548 + */ + public function test_login_prints_nothing_for_protocol_relative_admin_url_over_https(): void { + $_SERVER['HTTPS'] = 'on'; + $this->assertSame( 'https', wp_parse_url( admin_url(), PHP_URL_SCHEME ), 'Expected the admin to be served over https.' ); + + $redirect_to = (string) preg_replace( '#^https?:#', '', admin_url( 'post-new.php' ) ); + + $this->assertSame( array(), $this->get_prefetched_on_login( array( 'redirect_to' => $redirect_to ) ) ); + } + /** * Tests that the Dashboard and the post list tables prefetch only the editor's stylesheets. * From acae9f723b68e5994fc2deee368bc5fe690b2b59 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Oct 2026 11:22:25 -0700 Subject: [PATCH 63/71] Allow only http and https URLs among the prefetched resources The URLs a `prefetch_admin_assets` callback returns were escaped with `esc_url()` and its default protocols, so one with another scheme it allows, such as `mailto:` or `ftp:`, was printed as a prefetch link, which the script for browsers without `rel="prefetch"` would then pass to `fetch()`. They are now escaped with only `http` and `https` allowed, as `wp_preload_resources()` does, so any other URL is dropped. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 4 +++- .../phpunit/tests/dependencies/wpPrefetchAdminAssets.php | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 6089879755cc1..8f73cd6543b90 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -3002,8 +3002,10 @@ function wp_prefetch_admin_assets(): void { /* * Check again once escaped, since esc_url() returns an empty string for a URL it rejects. * An empty `href` would resolve to the current page, which the script below would then fetch. + * Only `http` and `https` URLs are allowed, as wp_preload_resources() does, since nothing else + * can be prefetched, and the script would pass anything else to fetch(). */ - $href = esc_url( $href ); + $href = esc_url( $href, array( 'http', 'https' ) ); if ( '' === $href || isset( $unique_resources[ $href ] ) ) { continue; diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 25869744eae36..171dc4a64fd9c 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -911,6 +911,15 @@ static function (): array { 'href' => 'javascript:alert(1)', 'as' => 'script', ), + // Allowed by esc_url() by default, but not prefetchable. + array( + 'href' => 'mailto:admin@example.com', + 'as' => 'document', + ), + array( + 'href' => 'ftp://example.com/file.js', + 'as' => 'script', + ), 'not an array', ); } From eb0e61401975ee2c45f654f0555df7c4e3995534 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Oct 2026 11:43:28 -0700 Subject: [PATCH 64/71] Clarify which sources WP_Styles::get_src() returns no URL for The comment on the check in `WP_Styles::get_src()` that returns an empty string read as though it handled a source of `true`, like that of `colors`, when it only catches an alias with no source; a source of `true` gets past it and takes its URL from the `style_loader_src` filter in `build_src()`. The comment now says both. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/class-wp-styles.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index be9db97c520db..dd806cdb6a2b9 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -477,7 +477,11 @@ public function get_src( string $handle ): string { $obj = $this->registered[ $handle ]; - // A handle whose source is `true`, like `colors`, gets its URL from the 'style_loader_src' filter. + /* + * An alias, with no source of its own, has no URL. A source of `true`, like that of `colors`, + * gets past this, since its URL comes from the 'style_loader_src' filter in build_src(), as + * it does when WP_Styles::do_item() prints the stylesheet. + */ if ( ! $obj->src ) { return ''; } From d706ef81bf2a16dce16783315f367904353303be Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 3 Oct 2026 18:06:25 -0700 Subject: [PATCH 65/71] Give the right-to-left stylesheet the same version and arguments as its style The URL of a right-to-left stylesheet was built with a version string carried over from the concatenation code in `WP_Styles::do_item()`, which differed from the one the left-to-right URL is built with in two ways: * A style registered with a `null` version, which opts out of `?ver=`, had its version turned into an empty string, which `build_src()` takes to mean the default version, so its right-to-left stylesheet got `?ver=` with the WordPress version after all. * Arguments added to the handle, as with `wp_enqueue_style( 'foo?color=blue' )`, were joined to the version with `&` and encoded into it, giving `?ver=1.0%26amp%3Bcolor%3Dblue` rather than `?ver=1.0&color=blue`. `get_rtl_src()`, which `do_item()` now prints the stylesheet with, passes the style's own version and handle to `build_src()`, as `get_src()` does, so both URLs get the same query. The `style_loader_src` filter is still passed the handle with `-rtl` appended, as it always has been, by way of a new optional parameter on the private `build_src()`. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/class-wp-styles.php | 32 +++++++------- .../tests/dependencies/wpStyles/getRtlSrc.php | 43 ++++++++++++++++++- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index dd806cdb6a2b9..32fc8878b6044 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -288,20 +288,15 @@ public function get_rtl_src( string $handle ): ?string { 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 ]; - } - + /* + * The version and the handle's added arguments are those of the left-to-right stylesheet, + * appended the same way, while the filter is passed the handle with `-rtl` appended, as it + * always has been. + */ if ( is_bool( $obj->extra['rtl'] ) || 'replace' === $obj->extra['rtl'] ) { $suffix = isset( $obj->extra['suffix'] ) && is_string( $obj->extra['suffix'] ) ? $obj->extra['suffix'] : ''; - return str_replace( "{$suffix}.css", "-rtl{$suffix}.css", $this->build_src( $obj->src, $ver, "$handle-rtl" ) ); + return str_replace( "{$suffix}.css", "-rtl{$suffix}.css", $this->build_src( $obj->src, $obj->ver, $handle, "$handle-rtl" ) ); } // Any other value is the URL of the right-to-left stylesheet itself. @@ -309,7 +304,7 @@ public function get_rtl_src( string $handle ): ?string { return null; } - return $this->build_src( $obj->extra['rtl'], $ver, "$handle-rtl" ); + return $this->build_src( $obj->extra['rtl'], $obj->ver, $handle, "$handle-rtl" ); } /** @@ -498,12 +493,17 @@ public function get_src( string $handle ): string { * * @param string|true $src The source of the style, or true for one whose URL comes * from the {@see 'style_loader_src'} filter. - * @param string|false|null $ver The version of the style. - * @param string $handle The style's registered handle. + * @param string|false|null $ver The version of the style. + * @param string $handle The style's registered handle, whose added arguments + * are appended. + * @param string|null $filter_handle Optional. The handle passed to the + * {@see 'style_loader_src'} filter, such as + * `{$handle}-rtl` for a right-to-left stylesheet. + * Default `$handle`. * @return string The filtered URL, or an empty string when the filter returns anything other * than a string. */ - private function build_src( $src, $ver, string $handle ): string { + private function build_src( $src, $ver, string $handle, ?string $filter_handle = null ): string { if ( ! is_bool( $src ) && ! preg_match( '|^(https?:)?//|', $src ) && ! ( $this->content_url && str_starts_with( $src, $this->content_url ) ) ) { $src = $this->base_url . $src; } @@ -543,7 +543,7 @@ private function build_src( $src, $ver, string $handle ): string { * @param string $src The source URL of the enqueued style. * @param string $handle The style's registered handle. */ - $src = apply_filters( 'style_loader_src', $src, $handle ); + $src = apply_filters( 'style_loader_src', $src, $filter_handle ?? $handle ); return is_string( $src ) ? $src : ''; } diff --git a/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php b/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php index 405415471f00f..c506c52490738 100644 --- a/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php +++ b/tests/phpunit/tests/dependencies/wpStyles/getRtlSrc.php @@ -36,7 +36,7 @@ public function set_up(): void { * @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 string|false|null $ver Version the style is registered with. * @param array $data Data added to the style. * @param string $expected Expected URL. */ @@ -52,13 +52,15 @@ public function test_builds_rtl_url( string $src, $ver, array $data, string $exp /** * Data provider for {@see self::test_builds_rtl_url()}. * - * @return array, 3: string }> + * @return array, 3: string }> */ public function data_builds_rtl_url(): array { return array( 'rtl true' => array( '/wp-admin/css/test.css', '1.0', array( 'rtl' => true ), 'http://example.org/wp-admin/css/test-rtl.css?ver=1.0' ), 'rtl replace' => array( '/wp-admin/css/test.css', '1.0', array( 'rtl' => 'replace' ), 'http://example.org/wp-admin/css/test-rtl.css?ver=1.0' ), 'rtl true, default version' => array( '/wp-admin/css/test.css', false, array( 'rtl' => true ), 'http://example.org/wp-admin/css/test-rtl.css?ver=7.2' ), + 'rtl true, no version' => array( '/wp-admin/css/test.css', null, array( 'rtl' => true ), 'http://example.org/wp-admin/css/test-rtl.css' ), + 'rtl URL, no version' => array( '/wp-admin/css/test.css', null, array( 'rtl' => 'https://cdn.example.com/test-rtl.css' ), 'https://cdn.example.com/test-rtl.css' ), 'rtl true, with suffix' => array( '/wp-admin/css/test.min.css', '1.0', @@ -115,6 +117,43 @@ public function data_returns_null(): array { ); } + /** + * Tests that the right-to-left URL gets the same version and added arguments as the + * left-to-right one, rather than the arguments being encoded into the version. + * + * @ticket 57548 + * + * @dataProvider data_matches_ltr_query + * + * @param string|false|null $ver Version the style is registered with. + */ + public function test_matches_ltr_query( $ver ): void { + $this->styles->add( 'test', '/wp-admin/css/test.css', array(), $ver ); + $this->styles->add_data( 'test', 'rtl', 'replace' ); + $this->styles->enqueue( 'test?color=blue' ); + + $ltr_src = $this->styles->get_src( 'test' ); + $this->assertStringContainsString( 'color=blue', $ltr_src ); + + $this->assertSame( + str_replace( 'test.css', 'test-rtl.css', $ltr_src ), + $this->styles->get_rtl_src( 'test' ) + ); + } + + /** + * Data provider for {@see self::test_matches_ltr_query()}. + * + * @return array + */ + public function data_matches_ltr_query(): array { + return array( + 'version' => array( '1.0' ), + 'default version' => array( false ), + 'no version' => array( null ), + ); + } + /** * Tests that the URL is passed through the {@see 'style_loader_src'} filter with the right-to-left handle. * From 7fe9568c2b29c090ec21e3574335350c3737703e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 5 Oct 2026 22:55:37 -0700 Subject: [PATCH 66/71] Name variable to indicate already escaped --- 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 8f73cd6543b90..361295aad2142 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -3019,10 +3019,10 @@ function wp_prefetch_admin_assets(): void { } // Build and output the HTML for each unique resource. Each URL has already been escaped. - foreach ( $unique_resources as $href => $as ) { + foreach ( $unique_resources as $escaped_href => $as ) { printf( "\n", - $href, + $escaped_href, esc_attr( $as ) ); } From 5a0cfaf92c41f3300bed046e3f0ed589d4daf121 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 5 Oct 2026 22:57:25 -0700 Subject: [PATCH 67/71] Type the prefetch links in the inline script as HTMLLinkElement `document.querySelectorAll()` is typed as returning `Element` for a selector other than a bare tag name, so reading `href` from each prefetch link was flagged as an unresolved property. A JSDoc cast to `NodeListOf` says what the `link[rel~="prefetch"][href]` selector returns. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 361295aad2142..2f3b4bca9d3f2 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -3052,7 +3052,7 @@ function wp_prefetch_admin_assets(): void { } const prefetch = () => { - for ( const link of document.querySelectorAll( 'link[rel~="prefetch"][href]' ) ) { + for ( const link of /** @type {NodeListOf} */ ( document.querySelectorAll( 'link[rel~="prefetch"][href]' ) ) ) { fetch( link.href, { mode: 'no-cors', credentials: 'include', priority: 'low' } ) .then( ( response ) => response.blob() ) .catch( () => {} ); From cf6e2a638fc09f2dd9319809e1fdb0bd277958d2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 5 Oct 2026 23:15:56 -0700 Subject: [PATCH 68/71] Rename the prefetch_admin_assets filter to wp_prefetch_admin_assets The filter applied in `wp_prefetch_admin_assets()` now has the same name as the function, as `wp_preload_resources` and `wp_resource_hints` have the names of the functions applying them, and as `wp_should_concatenate_admin_scripts` does in this change. It is renamed before it ships, so nothing needs deprecating. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 2 +- .../tests/dependencies/wpPrefetchAdminAssets.php | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 2f3b4bca9d3f2..c8505d9d0cfc0 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2978,7 +2978,7 @@ function wp_prefetch_admin_assets(): void { * will receive it, so a request-supplied path is passed through * unchanged and only the fallback is a full URL. */ - $resources = apply_filters( 'prefetch_admin_assets', $resources, $next_screen ); + $resources = apply_filters( 'wp_prefetch_admin_assets', $resources, $next_screen ); if ( ! is_array( $resources ) ) { return; diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 171dc4a64fd9c..4c8d80165008c 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -391,7 +391,7 @@ public function test_login_prints_nothing_for_absolute_front_end_redirect(): voi */ 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 ); + add_filter( 'wp_prefetch_admin_assets', array( $filter, 'filter' ), 10, 2 ); $links = $this->get_prefetched_on_login( array( 'redirect_to' => 'https://elsewhere.example.com/wp-admin/post-new.php' ) ); @@ -527,7 +527,7 @@ public function test_admin_screen_prefetches_editor_assets( string $screen, stri wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); $filter = new MockAction(); - add_filter( 'prefetch_admin_assets', array( $filter, 'filter' ), 10, 2 ); + add_filter( 'wp_prefetch_admin_assets', array( $filter, 'filter' ), 10, 2 ); $links = $this->get_prefetched_on_admin_screen( $screen ); @@ -620,7 +620,7 @@ public function test_admin_screen_prints_nothing_for_classic_editor(): void { add_filter( 'use_block_editor_for_post_type', '__return_false' ); $filter = new MockAction(); - add_filter( 'prefetch_admin_assets', array( $filter, 'filter' ) ); + add_filter( 'wp_prefetch_admin_assets', array( $filter, 'filter' ) ); $this->assertSame( array(), $this->get_prefetched_on_admin_screen( 'edit' ) ); $this->assertSame( 0, $filter->get_call_count(), 'Expected the filter not to be applied.' ); @@ -841,7 +841,7 @@ public function test_filter_receives_unescaped_stylesheet_urls(): void { $common_href = null; add_filter( - 'prefetch_admin_assets', + 'wp_prefetch_admin_assets', static function ( array $resources ) use ( &$common_href ): array { foreach ( $resources as $resource ) { if ( @@ -885,7 +885,7 @@ static function ( array $link ): bool { */ public function test_filter_result_is_deduplicated_and_sanitized(): void { add_filter( - 'prefetch_admin_assets', + 'wp_prefetch_admin_assets', static function (): array { return array( array( @@ -952,7 +952,7 @@ static function (): array { */ public function test_filter_can_turn_off_prefetching( $filtered ): void { add_filter( - 'prefetch_admin_assets', + 'wp_prefetch_admin_assets', static function () use ( $filtered ) { return $filtered; } From ebba75b2ab0e8ca3f0b231502a4c7c144a6d474c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 5 Oct 2026 23:45:09 -0700 Subject: [PATCH 69/71] Use the HTML Tag Processor to find the login footer's jQuery script and prefetch links The test of the login screen skipping its own footer scripts found jQuery's script tag with a regular expression over the printed markup, and compared its position with that of the first prefetch link using `strpos()`. It now walks the tags with `WP_HTML_Tag_Processor`, as the rest of the file does, and compares the order in which the jQuery `SCRIPT` and the first prefetch `LINK` occur. 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 4c8d80165008c..34ddf4167e1f9 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -683,10 +683,27 @@ public function test_login_skips_scripts_printed_in_footer(): void { $output = get_echo( 'do_action', array( 'login_footer' ) ); $links = $this->parse_prefetch_links( $output ); - $jquery_position = strpos( $output, '/wp-includes/js/jquery/jquery' ); - $this->assertIsInt( $jquery_position, 'Expected the login screen to load jQuery itself.' ); - $this->assertMatchesRegularExpression( '#]+src=[\'"][^\'"]*/wp-includes/js/jquery/jquery(\.min)?\.js#', $output, 'Expected jQuery to be loaded with a script tag.' ); - $this->assertGreaterThan( $jquery_position, strpos( $output, "rel='prefetch'" ), 'Expected the prefetch links to follow the footer scripts.' ); + // Find where jQuery's script tag and the first prefetch link are among the tags printed. + $processor = new WP_HTML_Tag_Processor( $output ); + $tag_index = 0; + $jquery_index = null; + $first_prefetch_index = null; + while ( $processor->next_tag() ) { + ++$tag_index; + + if ( 'SCRIPT' === $processor->get_tag() ) { + $src = $processor->get_attribute( 'src' ); + if ( is_string( $src ) && in_array( basename( (string) wp_parse_url( $src, PHP_URL_PATH ) ), array( 'jquery.js', 'jquery.min.js' ), true ) ) { + $jquery_index = $jquery_index ?? $tag_index; + } + } elseif ( 'LINK' === $processor->get_tag() && 'prefetch' === $processor->get_attribute( 'rel' ) ) { + $first_prefetch_index = $first_prefetch_index ?? $tag_index; + } + } + + $this->assertIsInt( $jquery_index, 'Expected the login screen to load jQuery itself with a script tag.' ); + $this->assertIsInt( $first_prefetch_index, 'Expected prefetch links to be printed.' ); + $this->assertGreaterThan( $jquery_index, $first_prefetch_index, 'Expected the prefetch links to follow the footer scripts.' ); $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery(\.min)?\.js#' ); $this->assertNotPrefetched( $links, '#/wp-includes/js/jquery/jquery-migrate(\.min)?\.js#' ); From 2a5c724cb786d3dc4236f9c81590517f6676ace3 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 6 Oct 2026 00:02:53 -0700 Subject: [PATCH 70/71] Type the as value of the prefetch test helpers as 'script'|'style' `get_hrefs()` and `assertPrefetched()` only ever look for prefetch links of scripts or stylesheets, and every call passes one of those literals, so their `$as_value` parameters are now documented as `'script'|'style'` rather than `string`. Co-Authored-By: Claude Opus 5.5 --- tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 34ddf4167e1f9..25e3a6e880d96 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -1104,7 +1104,7 @@ private function parse_prefetch_links( string $html ): array { * Gets the URLs prefetched with the given `as` value. * * @param list $links Prefetch links. - * @param string $as_value Value of the `as` attribute. + * @param 'script'|'style' $as_value Value of the `as` attribute. * @return list URLs. */ private function get_hrefs( array $links, string $as_value ): array { @@ -1123,7 +1123,7 @@ private function get_hrefs( array $links, string $as_value ): array { * 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 'script'|'style' $as_value Expected value of the `as` attribute. * @param non-empty-string $pattern Regular expression the URL must match. */ private function assertPrefetched( array $links, string $as_value, string $pattern ): void { From e5143554df825e01ebc69f7f6c1bae49ee18ed0c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 6 Oct 2026 00:07:53 -0700 Subject: [PATCH 71/71] Test that an escaped copy of a prefetched URL is collapsed with it The test of stylesheet URLs reaching the `wp_prefetch_admin_assets` filter unescaped had its callback append the plain form of the URL it received, by replacing `&` with `&`. That dated from when stylesheet URLs reached the filter escaped. They now arrive plain, as the test itself asserts, so the replacement did nothing and the callback appended an identical URL. The callback now appends the URL escaped with `esc_url()`, as a callback building it that way would, which differs from the plain one in its `&`. Duplicates are collapsed by the escaped URL, so the two still give one prefetch link, and the test checks that the appended URL really does differ. Co-Authored-By: Claude Opus 5.5 --- .../dependencies/wpPrefetchAdminAssets.php | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php index 25e3a6e880d96..3176ff87e3e16 100644 --- a/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php +++ b/tests/phpunit/tests/dependencies/wpPrefetchAdminAssets.php @@ -847,8 +847,8 @@ static function ( string $good_protocol_url, string $original_url, string $conte } /** - * 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. + * Tests that stylesheet URLs reach the filter unescaped, like script URLs, and that a callback + * appending the escaped form of one, as built with esc_url(), is collapsed with it. * * @ticket 57548 */ @@ -856,10 +856,11 @@ public function test_filter_receives_unescaped_stylesheet_urls(): void { // Give a stylesheet a query string of its own, so its URL has an `&` before the version. wp_styles()->registered['common']->src = '/wp-admin/css/common.css?color=blue'; - $common_href = null; + $common_href = null; + $escaped_href = null; add_filter( 'wp_prefetch_admin_assets', - static function ( array $resources ) use ( &$common_href ): array { + static function ( array $resources ) use ( &$common_href, &$escaped_href ): array { foreach ( $resources as $resource ) { if ( is_array( $resource ) && @@ -867,11 +868,12 @@ static function ( array $resources ) use ( &$common_href ): array { is_string( $resource['href'] ) && str_contains( $resource['href'], '/wp-admin/css/common.css' ) ) { - $common_href = $resource['href']; + $common_href = $resource['href']; + $escaped_href = esc_url( $resource['href'] ); - // Append the plain form of the same URL, as a callback building it itself would. + // Append the escaped form of the same URL, as a callback building it with esc_url() would. $resources[] = array( - 'href' => str_replace( '&', '&', $resource['href'] ), + 'href' => $escaped_href, 'as' => 'style', ); } @@ -886,13 +888,16 @@ static function ( array $resources ) use ( &$common_href ): array { $this->assertStringContainsString( '/wp-admin/css/common.css?color=blue&ver=', $common_href ); $this->assertStringNotContainsString( '&', $common_href ); + $this->assertIsString( $escaped_href ); + $this->assertStringContainsString( '&', $escaped_href, 'Expected the appended URL to differ from the one the filter received.' ); + $common_links = array_filter( $links, static function ( array $link ): bool { return str_contains( $link['href'], '/wp-admin/css/common.css' ); } ); - $this->assertCount( 1, $common_links, 'The plain form of the URL should be collapsed with it.' ); + $this->assertCount( 1, $common_links, 'The escaped form of the URL should be collapsed with it.' ); } /**