Script Loader: Prefetch assets for the next admin screen - #13084
Script Loader: Prefetch assets for the next admin screen#13084westonruter wants to merge 16 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>
| $script_handles = array(); | ||
| $style_handles = array(); |
There was a problem hiding this comment.
The lists of hard-coded handles below will need to be maintained. We should possibly add comments to where the assets are enqueued to note they should also note the list(s) here should be updated if anything changes.

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"and covers the editor as well. The headline measurements below were taken against the first revision and have not yet been re-run — details under "State of the measurements".What this does
Two contexts qualify today.
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. Prefetching them while the login form is on screen puts them in the cache during the time the user spends typing credentials. 24 tags on a default install.
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 — 42 tags.Handles the current screen has already printed are skipped, so each context only fetches what it actually adds.
Nothing is printed when concatenation is enabled; when the screen is not the login form (password reset, registration, logout, check-your-email); on an interim login; when
redirect_topoints outside the admin; on admin screens other than the Dashboard and post lists; for a user who cannot create the post type; or for a post type still using the classic editor.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.Implementation
Three functions in
src/wp-includes/script-loader.php:wp_prefetch_admin_assets()— decides whether a next screen can be predicted, builds the list and prints it. Hooked tologin_headandadmin_headat priority 10, after the current screen's own assets have printed so the already-printed check works._wp_expand_dependency_handles()— expands root handles to include everything they depend on._wp_resolve_dependency_urls()— resolves a registered handle to the URL it would load from, mirroringWP_Scripts::do_item()andWP_Styles::do_item(): the version argument, thescript_loader_srcandstyle_loader_srcfilters, and the RTL replace-or-append rules. Prints nothing, does not touch the queue.The editor set is expressed as 8 roots that expand to 18 handles, so it follows the dependencies declared in
wp_default_styles()rather than restating them —wp-edit-postalone accounts for most of the editor chrome. The admin-wide set is still a flat list.The
login_prefetch_admin_assetsfilter is renamedprefetch_admin_assets, since it is no longer login-specific. Its second argument is the URL of the screen being prefetched for.Why
Benchmarking the Dashboard on a throttled Fast 4G connection, 10 runs per condition, medians:
Once the cache is warm the difference vanishes into the run-to-run spread. The cold-cache gap is dominated by request serialization rather than bytes: measured over HTTP/1.1, where the six-connection-per-origin cap turns the 28 extra requests into roughly twenty round trips at 85 ms each. Expect it to shrink substantially over HTTP/2, so treat it as an upper bound on what concatenation is worth.
State of the measurements
The recovery figures below were measured on the first revision (b3809c8), which used
preloadand covered only the login screen. They have not been re-run. Fresh browser context per run, real login submit, 10 runs per arm, Fast 4G, medians. The control arm is the same login-to-Dashboard flow with the tags suppressed — necessary because the login screen warms ~21 shared assets on its own, so the hard-reload numbers above are not the right baseline for this flow.Distributions did not overlap: 1244–1280 ms vs 688–728 ms. Cost to the login screen itself was FCP 570 → 568 ms, but
load908 → 1597 ms.What still needs measuring:
loadregression.+689 msis a preload artifact — preloads blockload, a prefetch at idle priority should not. Possibly already gone.Correction to the earlier description
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.Design decisions
prefetch, notpreload. These are resources for the next navigation, which is what prefetch describes. Preload fetches at the current document's priority, makes cross-navigation reuse depend on static-file cache headers core does not control, 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, not the footer.
prefetchis body-ok so the footer would be valid, but the cost is small and already downstream of the critical path: 24 tags add 253 bytes gzipped on the login screen, 18 tags add 222 bytes on the Dashboard. Hook priority puts them after the current screen's own render-blocking CSS, so the preload scanner has found everything render-blocking before reaching a prefetch byte. Footer placement would move ~200 bytes out of a position already behind the critical path, at the cost of a later prefetch start.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 — bundled together precisely because they were concatenated. Checked across the Dashboard, Posts, Add New Post, Media, Plugins, Settings, Profile and Themes: all 6 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 used on the login screen:script_concat_settings()usually runs beforelogin_initfires, since registering any script oninitis enough to trigger it, and at that pointis_admin()is false, so the global settles onfalsewhatever the constant says. A side effect is that the login screen itself never concatenates even with the constant on. This gates onCONCATENATE_SCRIPTS && ! SCRIPT_DEBUGinstead. 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.Known gaps
use_block_editor_for_post_type()only exists in the admin, so the login-screen path cannot make that check. A classic-editor site withredirect_to=post-new.phpwill still prefetch editor CSS.redirect_toalone.colors) is universal but deliberately not prefetched from the login screen, since the scheme is a per-user setting and the user is unknown at that point. Same class of problem as the locale mismatch below.Review findings
Addressed: switched to
prefetch(2); restricted to the login form action and interim login (3, partly —Save-Datais still not honored); dropped the screen-specific handle; derived the editor list from roots rather than hardcoding it (5, partly — the admin-wide list is still flat); narrowed the filter's documented contract to the attributes actually printed (11).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; dropping the script handles (4); a drift test comparing the lists against the concat output (5); locale mismatch between the login screen and the admin (6);args/#fragmentin the script branch of the resolver (7); a docblock note that the src filters run in a logged-out, non-admin request (8); idempotency (12). No automated tests yet.Testing instructions
With
CONCATENATE_SCRIPTSfalse andSCRIPT_DEBUGfalse, view source and count<link rel="prefetch">tags:wp-login.phpwp-login.php?redirect_to=%2Fwp-admin%2Fpost-new.phpwp-login.php?redirect_to=%2Fwp-admin%2Fpost.php%3Fpost%3D1%26action%3Dtrashwp-login.php?redirect_to=%2Fhello-world%2Fwp-login.php?action=lostpassword,?interim-login=1post-new.php, Plugins, Settings, MediaCONCATENATE_SCRIPTStrueThen log in, or navigate from the Dashboard to Add New Post, and confirm the prefetched URLs match what the next screen requests and are served from cache. That last step is the one still unverified for prefetch.
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
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.