Script Loader: Prefetch assets for the next admin screen - #13084
westonruter wants to merge 69 commits into
Conversation
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 <noreply@anthropic.com>
|
Claude Code analysis of the change wp-admin script/style concatenation: phase 1 findings (HTTP/1.1, /wp-admin/ Dashboard)Measured on 2026-08-16 against the local Docker env (nginx:alpine, HTTP/1.1, Summary
ResultsFast 4G is Chrome DevTools' built-in preset applied via CDP
Delta of "concat off" versus "concat on" (positive = removing concat is slower):
Request shape per condition (from the network log):
Raw per-run values (ms)
Method
Caveats
Interpretation for the removal decision
Environment state left in place (revert notes)These local changes are still applied so phase 2 can continue; none are committed:
|
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
| 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 ); |
There was a problem hiding this comment.
It will be useful to print the preload links in login_footer. This would save some bytes before the body can load, especially if the list is long.
There was a problem hiding this comment.
I asked Claude and it suggests not moving it:
Real numbers, and they cut against moving it — though your mechanism is genuinely there.
The response is chunked, so your premise holds: Transfer-Encoding: chunked, Content-Encoding: gzip. Bytes in the head do sit in front of everything after them.
But the cost is 253 bytes gzipped.
| raw | gzip | |
|---|---|---|
| Login HTML without prefetch links | 10,492 | 3,476 |
| Login HTML with prefetch links | 13,383 | 3,729 |
| Cost of the 24 tags | +2,891 | +253 |
2.8 KB raw compresses to 253 bytes because the tags are near-identical — same scheme, host, path prefix, same ver string, same as value. The whole login document is 3.7 KB gzipped, comfortably inside a single initial congestion window, so there's no segment boundary for those 253 bytes to push anything past.
And they're already behind the render-blocking CSS. The hook priority does the work — print_admin_styles at 9, this at 10:
first stylesheet : line 7
last stylesheet : line 14
first prefetch : line 15
</head> : line 41
All 8 stylesheets are emitted before the first prefetch tag, so the preload scanner has discovered every render-blocking resource before it reaches a single prefetch byte. Moving them to the footer would relocate 253 bytes from a position that's already downstream of the critical path.
The measurement I have is a worst case. Login FCP was 568 ms with the links vs 570 ms without — and that was with 25 rel=preload tags, which are bigger (they carried fetchpriority='low') and fetch at document priority rather than idle. The version we now ship is strictly less aggressive on both axes than the thing that measured as free.
Where you'd be right and I'd change position: if the list grew substantially, if a site's head were already large enough that boundaries mattered, or if someone filtered in many more resources via login_prefetch_admin_assets. None of those is the default case, but none is exotic either.
So I'd keep it in login_head. If you want the bytes out regardless, footer is valid markup and I'll move it — it just trades 253 bytes of head for a later prefetch start, and start time is what determines whether the prefetch finishes before the user submits.
|
Combined review of Claude and Codex Preload the admin's unconcatenated assets from the login screenReviewed against VerdictThe mechanism works as described and the measured effect on the first Dashboard load after login is large. It is not a general answer to removing concatenation: it only helps a cold cache that happens to pass through What was verified
FindingsDesign
Correctness in
|
|
🤖 Claude analysis of benefit of preloading Dashboard load after logging in (10 runs per arm, fresh cache each run)
Distributions don't overlap at all — 1244–1280 ms vs 688–728 ms. That's 542 of the original 602 ms concat-on/off FCP gap, or 90%. Subtracting the login POST + redirect (~292 ms, present in both arms) to compare against the earlier direct-navigation numbers:
Preloading doesn't just close the gap — it beats concatenation, because the bytes move during idle time on the login screen instead of during the dashboard load. Cost to the login screen
Two caveats on reading thisWhy I ran a control arm despite you saying not to re-test. The earlier cold-cache number (1342 ms) was a hard reload of the dashboard, not a login→dashboard navigation. Going through the login screen warms 21 shared assets by itself, so that flow lands at 965 ms even with zero preloads. Without the control, preloading would have looked like it recovered 628 ms when the honest figure is 542 ms. Dwell time. Preloads finish at ~1570 ms; the runs used a fixed 4-second dwell on the login screen, identical in both arms. A user whose password manager submits in under ~1.6 s gets proportionally less. Everything is still HTTP/1.1, so the whole effect should shrink over HTTP/2. |
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
'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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Fix formatting issue in comments regarding script concatenation tests. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…into add/admin-script-style-preloading
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
…gin 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 <noreply@anthropic.com>
…s 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 <noreply@anthropic.com>
…reen 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…p 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
|
Pushing up commits from rounds of high-effort reviews from Opus 5.5. |
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…into add/admin-script-style-preloading
`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 <noreply@anthropic.com>
…irect() 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ts 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 <noreply@anthropic.com>



Explores one way to soften the cost of retiring script and style concatenation, per Core-57548: when the next screen can be predicted with confidence, prefetch what that screen is certain to need, so it is already in the HTTP cache by the time the user gets there.
This has changed substantially since the first revision, in response to the review on this PR. It used
rel="preload"and was login-only; it now usesrel="prefetch", covers the editor as well, and from the login screen prefetches only what blocks the admin's first paint. All the measurements below were taken against the current implementation; the full method and raw numbers are in the benchmark comments on this PR.What this does
Prefetching happens in two contexts, both of which predict the next screen.
The login screen → the admin. With concatenation off, the first admin screen downloads each core script and stylesheet separately, which is what makes an uncached admin load slower than a concatenated one. The ones that block its first paint — the stylesheets, and the scripts printed in the head — are prefetched while the login form is on screen, putting them in the cache during the time the user spends typing credentials. 19 tags on a default install. This happens on every screen of the login page, not only the login form: the password reset and registration flows end at the login form, and the admin email confirmation follows a login that has already succeeded, so each gives the prefetching more time to finish before the user reaches the admin.
The benchmarks below were taken when the login form prefetched 21 tags. The two since dropped are
jquery-coreandjquery-migrate, which the login form loads itself, by way ofuser-profile, so the Dashboard found them in the cache either way.The Dashboard and the post list tables → the editor. The editor is the usual next stop from both, and it is by far the heaviest screen in the admin. 18 tags.
The two compose: a login whose
redirect_topoints atpost-new.php, or atpost.php?action=edit, prefetches both sets — 37 tags.Handles the current screen has printed or queued are skipped, along with their dependencies, so each context only fetches what it actually adds. So are handles the next screen would print nothing for: one with conditional data, and a style whose own URL is filtered away, along with its right-to-left stylesheet.
Safari. Safari does not support
rel="prefetch", so after the links, a small inline script fetches the URL of each prefetch link on the page withfetch()in browsers whererelList.supports( 'prefetch' )is false. The fetches start once the screen has loaded, at low priority, and useno-corsmode with credentials, as the next screen's stylesheet and script requests do. In Safari, every URL fetched this way was then served from the disk cache on the next screen, for both login → Dashboard and Dashboard → Add New Post; see this comment for the HAR files and their analysis, and for the browser support and regional data behind it.Nothing is printed when concatenation is enabled; when
redirect_topoints outside this site's admin, including to an admin on another host, port or scheme, which would request its assets at other URLs (an explicit default port, such as:443forhttps, counts as no port, as browsers drop it when parsing a URL); on admin screens other than the Dashboard and post lists; for a user who cannot edit posts of the post type (checked withedit_postsrather thancreate_posts, since the editor is reached by opening an existing post as well as by adding a new one); or on any other request, such as one for the front end. The editor's stylesheets are left out for a post type still using the classic editor, from the login screen as well as from the admin.An interim login is not excluded: it shows inside a modal on an admin screen that has already loaded these assets, so its prefetches are served from the HTTP cache. On the lost password and registration screens,
redirect_tois where submitting the form goes rather than where the login lands; it is normally absent, so the admin is assumed, and when present it typically points back intowp-login.php, so nothing is printed. An emptyredirect_tocounts as none, as it does forwp-login.php, and one for the admin itself without its trailing slash, such as/wp-admin, counts as the admin.redirect_tois resolved withwp_validate_redirect()alone, sincewp_safe_redirect()sends the browser to whatever that returns. It sanitizes the value, resolves a relative path such aswp-admin/post-new.phpagainst the current request as the browser would, gives a protocol-relative value thehttpscheme, and falls back to the admin for one pointing off-host.Why: login → Dashboard, today's default vs this PR
Fresh browser context per run (empty cache), real login submitted 3 s after the login screen loaded,
SCRIPT_DEBUGoff, gzip and one-year static caching on, 10 runs per arm interleaved, medians with the full range in brackets. Three arms:loadloadAgainst today's default, this PR's configuration gives the Dashboard an LCP 294 ms (−26%) faster on Fast 4G and 1,622 ms (−46%) faster on Slow 4G, with the three arms' ranges well separated, while DOMContentLoaded and
loadstay within 1.5% (+54 ms and +16 ms). Retiring concatenation without prefetching would cost 278–312 ms of LCP and 0.5–1.9 s of DOMContentLoaded.Prefetching overtakes concatenation on LCP largely because concatenation defeats caching across screens. The login screen concatenates too when the constant is on, but into bundles of its own:
dashicons,buttons,forms,l10n,wp-base-styles,wp-tooltip,loginclipboard,jquery-core,jquery-migrate,zxcvbn-async,wp-hooksA bundle is only reused when its URL, and so its exact list of handles, matches. The Dashboard's bundles (styles; head scripts
jquery-core,jquery-migrate,utils; footer scriptshoverIntent,wp-dom-ready,wp-hooks) match neither of the login screen's, so they download from scratch, even though most of what they contain was just downloaded inside the login bundles. With concatenation off, the Dashboard reuses the six files the login screen already loaded plus the 21 prefetched ones.Some of the lead belongs to prefetching as such, though: a concatenated setup could in principle prefetch the next screen's bundle URLs. That has not been measured.
The login screen itself pays for concatenation being off, not for the prefetching: its
loadgoes from 698 to 912 ms on Fast 4G and from 2418 to 3181 ms on Slow 4G, the same as the no-prefetch arm (920 and 3195 ms). Its FCP barely moves (+52 ms on Fast 4G, −80 ms on Slow 4G).In current Chrome, "Slow 4G" is the preset formerly named "Fast 3G"; the prefetch arm reproduced the earlier Fast 3G runs to within 12 ms.
Further along: the Posts list and the block editor
A longer journey was also benchmarked, with the same three arms: login → Dashboard → Posts list → editing an existing post in the block editor. It reproduces the Dashboard results above, and the gains carry forward. Full tables and method are in the journey benchmark comment. In brief, with prefetch compared with today's default (concatenation):
site-health, so its URL differs and the whole bundle, about 150 KB, downloads again.Render-blocking only, from the login screen
The login set used to be whatever
load-scripts.phpandload-styles.phpbundle, but being concatenated was only a stand-in for what matters. With concatenation off, three of the six bundled scripts (hoverIntent,wp-dom-ready,wp-hooks) print in the footer, and Chrome marks only the stylesheets and the three head scripts (jquery-core,jquery-migrate,utils) as render-blocking on the Dashboard. Dropping the three footer scripts made no measurable difference to anything.Prefetching the footer scripts as well was benchmarked, since they do block DOMContentLoaded. When the prefetch finishes before the login is submitted the gain is large — DOMContentLoaded 3.6 s → 1.05 s on Fast 4G, LCP unchanged — but the set is about 1.2 MB gzipped, 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 carried 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 is left out.
Stylesheets only, for the editor
The editor's incremental cost over the Dashboard is 47 files. Split by type:
Only the stylesheets are prefetched.
wp-editorJS alone is 503 KB gzipped andwp-block-libraryanother 356 KB; over a megabyte of speculative download for a screen the user may never open is not a reasonable default, especially on a metered connection. The stylesheets are render-blocking and land in the same size class as the login screen's own prefetch. Prefetching the editor's scripts on an explicit intent signal — hover or focus on an Add New link — would be a reasonable follow-up, where the prediction is strong enough to justify the bytes.These figures predate
wp-editorgaining a dependency on thewp-media-utilsstylesheet, which the root expansion picked up without a code change.Implementation
wp_prefetch_admin_assets()insrc/wp-includes/script-loader.phpdecides whether a next screen can be predicted, builds the list and prints it. It is hooked tologin_footerat priority 21 indefault-filters.php, sincewp-login.phpenqueues some of what the login form loads, such asuser-profile, only after its header has printed, and toadmin_headinadmin-filters.php, where the screen's assets have been enqueued already. The context is told apart by the request rather than by the hook:did_action( 'login_init' )for the login screen, asscript_concat_settings()does, and the$current_screenglobal for an admin screen, read directly rather than throughget_current_screen(), which only exists once the admin includes are loaded. So it works from whichever hook it is added to, and prints nothing, without an error, anywhere else.is_login()is deliberately not used: it compares the login URL with the script handling the request, so it misses the login screen when a plugin serves it at a URL of its own, runningwp-login.phpfromindex.php. After the links, it prints the Safari polyfill throughwp_print_inline_script_tag(), so it gets a nonce under a Content Security Policy. The script reads the links from the page (link[rel~="prefetch"]) rather than being passed their URLs, so there is one list of them, and prefetch links added by plugins are covered too. Unlike a prefetch link, afetch()still in flight is canceled when the user navigates away, so it cannot compete with the next screen's own assets. Nothing is printed, script included, when no resources are left to prefetch.WP_Scripts::get_src()is new: the URL-building that was inline inWP_Scripts::do_item(), moved into a method thatdo_item()now calls. It is modeled onWP_Script_Modules::get_src().WP_Styles::get_src()is new and mirrorsWP_Scripts::get_src(). The URL building inWP_Styles::_css_href(), up to and including thestyle_loader_srcfilter, moved into a privatebuild_src()._css_href()still returns it escaped for an HTML attribute.WP_Styles::get_rtl_src()is new: likewise the right-to-left URL logic fromWP_Styles::do_item(), whichdo_item()now calls. It returnsnullwhen no right-to-left stylesheet applies, sodo_item()prints one in exactly the same cases as before.Like
WP_Script_Modules::get_src(), all three return the URL after its filter but neither sanitized nor escaped, and each caller sanitizes or escapes it once:WP_Scripts::do_item()withesc_url_raw()andWP_Styles::do_item()withesc_url(), as before, and the prefetching withesc_url(). So theclean_urlfilter runs once for each URL, whether it is printed in a tag or prefetched. An earlier revision returned the URLs throughesc_url_raw(), which made the filter run a second time for each prefetched URL.So the
prefetch_admin_assetsfilter sees plain URLs for scripts and stylesheets alike.build_src()treats a filter result that is not a string as an empty string, asWP_Scripts::get_src()does. Before,_css_href()passed such a result toesc_url(), printingtrueor a number as anhref, raising a deprecation notice fornull, and failing with aTypeErrorfor an array or object.The prefetch builds its URLs the same way
do_item()builds those in its tags, so they cannot drift apart. An earlier revision copied that code into a private helper, and the copy had already fallen behind: it dropped the handle's query arguments and the URL fragment. Both sets of URLs are identical for every registered script and style (237 and 421), in both text directions.Both sets are expressed as roots, so they follow the dependencies declared in
wp_default_scripts()andwp_default_styles()rather than restating them. They are expanded to everything they depend on inline, since there is only one caller. The current screen's queue is expanded the same way, to find what it will load itself, withoutWP_Dependencies::all_deps(), which would change the dependencies' state.jquery,utils) and 5 style roots (wp-admin,buttons,admin-bar,wp-auth-check,wp-commands). These expand to the same 24 stylesheets and 3 scripts the earlier flat list named.wp-edit-post, which alone accounts for most of the editor chrome.Each root mirrors an enqueue elsewhere. That enqueue now carries a comment saying
wp_prefetch_admin_assets()needs updating if it changes, and the comment above the roots lists those enqueues, so the two can be kept in sync from either side. Roots already reachable from another root were dropped, leaving fewer enqueues to keep in sync:wp-pointer(reached throughwp-admin) and the editor'swp-block-editor-contentandwp-reset-editor-styles(reached throughwp-edit-post).The filter is
prefetch_admin_assets, since it is no longer login-specific. It is modeled onwp_preload_resources: it receives a plain list of attribute arrays, eachhrefis escaped withesc_url()after the filter runs, allowing onlyhttpandhttpsaswp_preload_resources()does, and dropped if that leaves it empty (an emptyhrefwould resolve to the current page, which the Safari polyfill would then fetch, as it would amailto:orftp:URL), duplicates by escapedhrefare collapsed with the first entry winning, andastakes any destination, not justscriptandstyle. Its second argument is the URL of the screen being prefetched for.wp_should_concatenate_admin_scripts()is new, with a filter of the same name. It holds the default for concatenation on admin screens and the login screen, which is theCONCATENATE_SCRIPTSconstant (true when undefined) overridden bySCRIPT_DEBUG.script_concat_settings()uses it to set the$concatenate_scriptsglobal, andwp_prefetch_admin_assets()uses it to predict, from the login screen, whether the admin will concatenate. Before, the prefetch restated that default, so changing it inscript_concat_settings()alone, as #13090 does, would have left the login screen predicting concatenation and printing nothing. The filter also lets the tests turn concatenation off without defining a constant, so they no longer need separate processes.Note
The
wp_should_concatenate_admin_scriptsfilter is likely temporary and expected to be removed before 7.2-beta1. It exists while concatenation is still an option. Once concatenation is retired in Core-57548, the function and its filter have nothing left to decide. Plugins should not rely on the filter. Setting the$concatenate_scriptsglobal remains the established way to override concatenation on a request. The function's docblock says so too.While it exists, a callback has to be added when a plugin loads. An admin screen settles the global the first time scripts are registered, when
wp_default_scriptsregisters TinyMCE, which can happen while plugins are still loading, whereas the login screen reads the filter only when it prints. A callback added from a theme, or on a later hook, could be missed by the admin but seen by the login screen, which would then predict the wrong setting. The filter's docblock notes this.Other admin flows
The benchmarks follow one journey: login → Dashboard → Posts → editor. To check that optimizing for it does not disadvantage other flows, such as Dashboard → Comments or Dashboard → Settings, the scripts and stylesheets of 13 admin screens were compared with those the Dashboard loads. This was done with concatenation off, as the administrator, with Twenty Twenty-Five active; the figures include the assets of the plugins active on that install.
Almost everything the Dashboard loads is reused elsewhere. The Dashboard loads 113 scripts and stylesheets, about 1.38 MB gzipped:
About 30 of the Dashboard's 113 assets are its own, mostly its widgets and
site-health.The other flows gain as much as this one:
The editor prefetch is the one cost. The Dashboard and post lists prefetch 17 editor stylesheets, about 78 KB gzipped:
edit-sitescript and stylesheet are its own.The editor's scripts are not prefetched, only its stylesheets: of the editor's new assets, the prefetch covers about 5% of the bytes.
This is why, in the journey benchmark, prefetching cut the editor's FCP by 70% on Slow 4G but brought it to "ready" only 15% sooner: the stylesheets block the first paint, while the content waits on about 1.5 MB of scripts. Prefetching those scripts on a stronger signal of intent, such as hover or focus on an Add New or Edit link, would cover the rest for both editors.
Reuse across the navigation
Verified for prefetch in Chrome, fresh isolated browser context per run, Fast 4G, reading Resource Timing and the network log on the Dashboard after a real login submit:
Last-ModifiedandETagonlyLast-ModifiedandETagonlyCache-Control: max-age=31536000Reuse is governed by ordinary HTTP freshness. Without explicit caching headers the browser estimates freshness as a tenth of the time since
Last-Modified. The 18 that revalidated at 241 s were exactly the files rebuilt minutes earlier, whose estimated freshness had run out; the six still served from cache were old enough to stay fresh for hours or days. No five-minute exemption for unused prefetches applied. A 304 still saves the download, but costs a round trip.A freshly built development checkout is close to the worst case for this. On a production site core's files are typically unchanged since the last update, which gives a heuristic lifetime of days, and many hosts send an explicit long
max-agefor CSS and JS besides. Far-future caching headers are not required for this to work — see the caching comment on this PR for the conditions and the HTTP Archive data.What still needs measuring
Corrections to earlier descriptions
The first revision claimed no "preloaded but not used" console warnings appeared. That was wrong — the check used a tool that surfaces JS
console.*calls but not browser-generated warnings, so it could not have observed them. Thanks to @manzoorwanijk for catching it. The switch to prefetch moots the warnings, but the claim should not have been made.A later revision also argued that preload, unlike prefetch, would make reuse depend on static-file cache headers core does not control. That was wrong too: prefetch depends on them in exactly the same way, as the reuse test above shows. The docblock has been corrected to match.
Earlier revisions also said the login screen never concatenates even with the constant on. That was too strong: it depends on whether
script_concat_settings()runs beforelogin_init, as explained under "The gate is a prediction" below, and on the setup used for the benchmarks above it does concatenate.Design decisions
prefetch, notpreload. These are resources for the next navigation, which is what prefetch describes. Preload fetches at the current document's priority and warns about resources the document never uses.No
fetchpriority. A prefetch is already dispatched at the lowest priority.asis kept — it gives the request the same destination the next screen will ask for, which is what lets the response be reused.In the head on admin screens, in the footer on the login screen. On admin screens the links go in the head: the cost is small and already downstream of the critical path (when measured, the Dashboard's 18 tags added 222 bytes gzipped), and hook priority puts them after the screen's own render-blocking CSS, so the preload scanner has found everything render-blocking before reaching a prefetch byte. On the login screen they go in the footer, which
prefetchallows, sincewp-login.phpenqueuesuser-profile, and with itjquery, only after its header has printed; only by the footer is it known that the login form loads jQuery itself. The login screen's markup is small, so the later start costs little.The admin-wide list does not vary by destination. Nearly all of it is universal admin CSS rather than Dashboard CSS:
wp-adminis an alias handle enqueued on every admin screen that pulls indashboard,edit,themes,nav-menus,widgets,revisionsand the rest. Checked across the Dashboard, Posts, Add New Post, Media, Plugins, Settings, Profile and Themes: the head scripts and 24 of the original 25 styles appear on every one.site-healthwas the sole exception and was dropped.The gate is a prediction, not a reading.
$concatenate_scriptscannot be relied on from the login screen: ifscript_concat_settings()runs beforelogin_initfires — registering a script oninitis enough to trigger it — it evaluatesis_admin()as false and settles the global onfalsewhatever the constant says, and the login screen then does not concatenate either. The login screen gates onwp_should_concatenate_admin_scripts()instead. That prediction can be wrong if a plugin pre-sets the global or defines the constant only whenis_admin(). The underlying quirk looks worth its own ticket. On admin screens there is nothing to predict: the global has been set by the time the head is printed, soadmin_headreads it directly, and a plugin that sets it is respected there.Known gaps
redirect_to, and an edit link topost.phpdoes not carry one, so it is taken to be a post. Looking the post up would be more accurate, but it would let anyone tell from the login screen whether a post with a given ID exists, drafts and private posts included. So on a site where posts and pages differ in which editor they use, a login redirecting to edit a page can guess wrong.use_block_editor_for_post_type(). The Classic Editor plugin's mode that lets users switch editors filtersuse_block_editor_for_postinstead, per post and per user, so in that mode the editor's stylesheets are still prefetched for users who default to the classic editor. From the login screen the user is not known yet; from the admin it could be checked against a stand-in post, which has not been done.redirect_toalone.redirect_tois not resolved exactly aswp-login.phpwill resolve it after the login:wp-login.phprewrites anhttp://redirect_tocontainingwp-admintohttps://. The user is not known yet, so the login screen prefetches thehttp://URLs, which that admin will not reuse.login_redirectfilter is not applied, since it takes the user, who is not known yet. Where it sends users somewhere else, such as a subscriber to the front end, the prefetch is wasted.redirect_towith.or..segments is checked before the browser resolves them, so/wp-admin/../counts as the admin and/wp-admin/network/..does too, correctly. Only a hand-crafted URL would contain them: browsers resolve such segments before sending a request, so aredirect_tobuilt from the request URI, asauth_redirect()builds it, has none. At worst the admin's assets are prefetched for nothing, or not prefetched, so resolving them here was judged not worth the code.colors) is universal and render-blocking but deliberately not prefetched from the login screen, since the scheme is a per-user setting and the user is unknown at that point. It is the only render-blocking core stylesheet on every admin screen that is not covered. Same class of problem as the locale mismatch below.fetch()calls already behave that way, since the browser cancels them on navigation, so extending the same approach to all browsers is an option, but it has not been tried.Review findings
Addressed: switched to
prefetch(2); restricting to the login form action and excluding interim login (3) was done and later reverted, since the other login screens mostly lead to the admin too and an interim login's prefetches are cache hits (Save-Datais still not honored); dropping the script handles (4, partly — the three footer scripts are gone, the three head scripts stay because they block rendering); dropped the screen-specific handle; derived both the login and editor lists from roots rather than hardcoding them, with comments linking each root and its enqueue (5, partly — there is no drift test); URLs now come from the same methodsdo_item()uses, which fixes theargs/#fragmenthandling (7); documented in the filter's docblock that on the login screen the src filters run before authentication and outside the admin, so a callback depending on either can produce a URL the admin will not request (8); narrowed the filter's documented contract to the attributes actually printed (11); Fast 3G / Slow 4G.Not addressed: framing this as a complement rather than a replacement (1) — agreed, and it should not be the argument for retiring
load-styles.php; a drift test comparing the roots against what the admin actually loads (5); locale mismatch between the login screen and the admin (6); idempotency (12).Tests
Tests_Dependencies_WpPrefetchAdminAssetscoverswp_prefetch_admin_assets():redirect_to, including an empty one, a relative one, one for the admin without its trailing slash, one on another host, port or scheme, one with an explicit default port, and a protocol-relative one overhttpandhttpsdefault-filters.phpregisters, and right-to-left stylesheetsclean_urlfilter runs once for each prefetched URLesc_url()rejects and one with a scheme other thanhttporhttps$concatenate_scriptsglobal, while admin screens read itwp_should_concatenate_admin_scriptsfilter, so they all run in the main process (about 0.5 s for 68 tests, down from about 37 s when each definedCONCATENATE_SCRIPTSin a separate process).Tests_Dependencies_WpShouldConcatenateAdminScriptscovers the new function: the constants, the filter, andscript_concat_settings()taking its default from it. Only the two tests that defineCONCATENATE_SCRIPTSrun in a separate process.Tests_Dependencies_WpScripts_GetSrc,Tests_Dependencies_WpStyles_GetSrcandTests_Dependencies_WpStyles_GetRtlSrccover the three new methods, including that each returns the URLdo_item()prints, that the right-to-left URL gets the same version and added arguments as the left-to-right one, and that printing a right-to-left stylesheet runs theclean_urlfilter only once for its URL.Two changes beyond the prefetching came along, each in a separate commit, so it can be committed apart from the rest.
A fix to the right-to-left stylesheet's URL. Writing those tests turned up an existing bug in
WP_Styles::do_item(): the right-to-left URL was built with a version string carried over from the concatenation code. A style registered with anullversion, which opts out of?ver=, still got the default version on its right-to-left stylesheet, and arguments added to the handle, as withwp_enqueue_style( 'foo?color=blue' ), were encoded into the version (?ver=1.0%26amp%3Bcolor%3Dblue) rather than appended (?ver=1.0&color=blue).get_rtl_src()now passes the style's own version and handle tobuild_src(), asget_src()does, and sincedo_item()prints the stylesheet with it, the printed tag is fixed too. Thestyle_loader_srcfilter is still passed the handle with-rtlappended, as it always has been.A deprecation notice naming a method consistently. The test for conditional data turned up that the deprecation notice for passing
conditionaltoadd_data()named the methodWP_Dependencies->add_data(), the only->among the methods named in core's_deprecated_*()and_doing_it_wrong()calls. It is nowWP_Dependencies::add_data(), and the@expectedDeprecatedannotations of the tests that add conditional data are updated to match. One plugin in the directory, with about 50 installs, matches the old string to hide that notice, so it would show for those sites until the plugin is updated.Testing instructions
With
CONCATENATE_SCRIPTSfalse andSCRIPT_DEBUGfalse, and the block editor enabled for posts and pages, view source and count<link rel="prefetch">tags:wp-login.php,?interim-login=1wp-login.php?action=lostpassword,?checkemail=confirm(these do not load jQuery themselves)wp-login.php?redirect_to=%2Fwp-admin%2Fpost-new.php,…post.php%3Fpost%3D1%26action%3Dedit,?redirect_to=wp-admin%2Fpost-new.phpwp-login.php?redirect_to=%2Fwp-admin%2Fpost.php%3Fpost%3D1%26action%3Dtrash,?redirect_to=%2Fwp-adminwp-login.php?redirect_to=%2Fhello-world%2F,?redirect_to=example.org%2Fwp-admin%2Fwp-login.phpoverhttp, withredirect_toset to this site'shttps://…/wp-admin/post-new.php, Plugins, Settings, MediaCONCATENATE_SCRIPTStrueWith the Classic Editor plugin active in its default mode, the Dashboard and list tables print 0, and a login redirecting to
post-new.phpprints only the admin's 19.To check reuse, load the login screen, log in within a few seconds, and confirm in the Network panel that the prefetched URLs are served from the cache on the Dashboard without a request. Waiting a few minutes before logging in on a freshly built checkout will show 304s instead, per "Reuse across the navigation".
Trac ticket: https://core.trac.wordpress.org/ticket/57548
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5, Claude Opus 5.5
Used for: Running the benchmarks and asset analysis, drafting the implementation, and drafting this description. The approach, the design decisions and the final code were reviewed and edited by me.
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.