Skip to content

Script Loader: Prefetch assets for the next admin screen - #13084

Open
westonruter wants to merge 69 commits into
WordPress:trunkfrom
westonruter:add/admin-script-style-preloading
Open

westonruter wants to merge 69 commits into
WordPress:trunkfrom
westonruter:add/admin-script-style-preloading

Conversation

@westonruter

@westonruter westonruter commented Aug 16, 2026 •

Copy link
Copy Markdown
Member

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 uses rel="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-core and jquery-migrate, which the login form loads itself, by way of user-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_to points at post-new.php, or at post.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 with fetch() in browsers where relList.supports( 'prefetch' ) is false. The fetches start once the screen has loaded, at low priority, and use no-cors mode 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_to points 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 :443 for https, 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 with edit_posts rather than create_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_to is 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 into wp-login.php, so nothing is printed. An empty redirect_to counts as none, as it does for wp-login.php, and one for the admin itself without its trailing slash, such as /wp-admin, counts as the admin.

redirect_to is resolved with wp_validate_redirect() alone, since wp_safe_redirect() sends the browser to whatever that returns. It sanitizes the value, resolves a relative path such as wp-admin/post-new.php against the current request as the browser would, gives a protocol-relative value the http scheme, 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_DEBUG off, gzip and one-year static caching on, 10 runs per arm interleaved, medians with the full range in brackets. Three arms:

  • concat — today's default, concatenation on. The login screen prints no prefetch links.
  • none — concatenation off and nothing prefetched: what retiring concatenation would cost on its own.
  • prefetch — concatenation off, with this PR.
concat none prefetch
Fast 4G — LCP 1134 ms [1104–1164] 1412 ms [1360–1536] 840 ms [824–880]
Fast 4G — DOMContentLoaded 3596 ms 4105 ms 3650 ms
Fast 4G — load 3597 ms 4107 ms 3651 ms
Fast 4G — Dashboard requests / transferred 83 / 1,511 KB 118 / 1,456 KB 99 / 1,367 KB
Slow 4G — LCP 3494 ms [3464–3508] 3806 ms [3764–3836] 1872 ms [1852–1880]
Slow 4G — DOMContentLoaded 13286 ms 15207 ms 13302 ms
Slow 4G — load 13287 ms 15209 ms 13303 ms
Slow 4G — Dashboard requests / transferred 81 / 1,490 KB 116 / 1,437 KB 97 / 1,346 KB

Against 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 load stay 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:

  • styles: dashicons,buttons,forms,l10n,wp-base-styles,wp-tooltip,login
  • scripts: clipboard,jquery-core,jquery-migrate,zxcvbn-async,wp-hooks

A 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 scripts hoverIntent,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 load goes 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):

  • Posts list: FCP is 58% faster on Fast 4G and 70% faster on Slow 4G (2.1 s → 0.6 s), and 84% less is transferred (175 KB → 28 KB). This comes from dropping concatenation rather than from prefetching. The list screen's two script bundles have the same URLs as the Dashboard's and are reused, but its stylesheet bundle lacks the Dashboard-only site-health, so its URL differs and the whole bundle, about 150 KB, downloads again.
  • Block editor: because the Dashboard and the list table prefetch the editor's stylesheets, FCP is 44% faster on Fast 4G and 70% faster on Slow 4G (4.7 s → 1.4 s). The editor is ready (post title visible in the canvas) 11% sooner on Fast 4G and 15% sooner on Slow 4G (15.1 s → 12.8 s), with 19% fewer bytes transferred.

Render-blocking only, from the login screen

The login set used to be whatever load-scripts.php and load-styles.php bundle, 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:

Subset Handles Gzipped Raw
CSS 17 97.6 KB 634.8 KB
JS 30 1,261.8 KB 4,184.6 KB
Everything 47 1,359.4 KB 4,819.5 KB

Only the stylesheets are prefetched. wp-editor JS alone is 503 KB gzipped and wp-block-library another 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-editor gaining a dependency on the wp-media-utils stylesheet, which the root expansion picked up without a code change.

Implementation

  • wp_prefetch_admin_assets() in src/wp-includes/script-loader.php decides whether a next screen can be predicted, builds the list and prints it. It is hooked to login_footer at priority 21 in default-filters.php, since wp-login.php enqueues some of what the login form loads, such as user-profile, only after its header has printed, and to admin_head in admin-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, as script_concat_settings() does, and the $current_screen global for an admin screen, read directly rather than through get_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, running wp-login.php from index.php. After the links, it prints the Safari polyfill through wp_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, a fetch() 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 in WP_Scripts::do_item(), moved into a method that do_item() now calls. It is modeled on WP_Script_Modules::get_src().
  • WP_Styles::get_src() is new and mirrors WP_Scripts::get_src(). The URL building in WP_Styles::_css_href(), up to and including the style_loader_src filter, moved into a private build_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 from WP_Styles::do_item(), which do_item() now calls. It returns null when no right-to-left stylesheet applies, so do_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() with esc_url_raw() and WP_Styles::do_item() with esc_url(), as before, and the prefetching with esc_url(). So the clean_url filter runs once for each URL, whether it is printed in a tag or prefetched. An earlier revision returned the URLs through esc_url_raw(), which made the filter run a second time for each prefetched URL.

So the prefetch_admin_assets filter sees plain URLs for scripts and stylesheets alike. build_src() treats a filter result that is not a string as an empty string, as WP_Scripts::get_src() does. Before, _css_href() passed such a result to esc_url(), printing true or a number as an href, raising a deprecation notice for null, and failing with a TypeError for 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() and wp_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, without WP_Dependencies::all_deps(), which would change the dependencies' state.

  • The login screen has 2 script roots (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.
  • The editor has 6 style roots, led by 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 through wp-admin) and the editor's wp-block-editor-content and wp-reset-editor-styles (reached through wp-edit-post).

The filter is prefetch_admin_assets, since it is no longer login-specific. It is modeled on wp_preload_resources: it receives a plain list of attribute arrays, each href is escaped with esc_url() after the filter runs, allowing only http and https as wp_preload_resources() does, and dropped if that leaves it empty (an empty href would resolve to the current page, which the Safari polyfill would then fetch, as it would a mailto: or ftp: URL), duplicates by escaped href are collapsed with the first entry winning, and as takes any destination, not just script and style. 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 the CONCATENATE_SCRIPTS constant (true when undefined) overridden by SCRIPT_DEBUG. script_concat_settings() uses it to set the $concatenate_scripts global, and wp_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 in script_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_scripts filter 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_scripts global 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_scripts registers 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:

Screen Assets Already loaded by the Dashboard New New (gzipped)
Comments 94 94 0 0 KB
Plugins 91 91 0 0 KB
Users 83 83 0 0 KB
Tools 83 83 0 0 KB
Posts 88 86 2 4 KB
Categories 86 84 2 3 KB
Profile 91 87 4 5 KB
Themes 93 89 4 33 KB
Settings → General 112 91 21 162 KB (the media modal, for the site icon)
Media 122 91 31 965 KB (media library, Plupload, MediaElement)
Add New Post 158 100 58 1,570 KB
Site Editor 148 93 55 1,947 KB

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 login prefetch is admin-wide. It is the render-blocking set that every admin screen loads, so it helps equally when the login lands on Comments, Settings or anywhere else.
  • Turning concatenation off helps every screen. With concatenation, each screen's bundles are only reused when their exact list of handles matches, and between the Dashboard and Comments the stylesheet bundle differs, just as it does for the Posts list. Without concatenation, Comments needs only its HTML.

The editor prefetch is the one cost. The Dashboard and post lists prefetch 17 editor stylesheets, about 78 KB gzipped:

  • The editors use nearly all of it. Add New Post uses 16, and the Site Editor 15. The Site Editor shares 53 of its 55 new assets with the post editor; only the edit-site script and stylesheet are its own.
  • Settings and Media use 4 (about 13 KB, the media modal's CSS). The other screens use none.
  • So a flow such as Dashboard → Comments downloads about 78 KB it does not need, at the lowest priority. It happens once per cache lifetime, since the files stay cached for later Dashboard loads. If the user navigates before the prefetch finishes, the requests continue alongside the next screen; Comments needs no new assets, so there is nothing to compete with, while Settings needs 162 KB, so a slow connection could see a little contention there.

The editor's scripts are not prefetched, only its stylesheets: of the editor's new assets, the prefetch covers about 5% of the bytes.

Screen New scripts New stylesheets Prefetched from the Dashboard
Add New Post 38 files, 1,473 KB 20 files, 97 KB 16 stylesheets, 77 KB
Site Editor 36 files, 1,835 KB 19 files, 113 KB 15 stylesheets, 75 KB

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:

Login submitted Static-file caching headers Served from cache, no request Revalidated (304)
~4 s after the prefetch Last-Modified and ETag only 24 / 24 0
241 s after the prefetch Last-Modified and ETag only 6 / 24 18
241 s after the prefetch Cache-Control: max-age=31536000 21 / 21 0

Reuse 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-age for 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

  1. Add New Post specifically. Opening an existing post from the Posts list has been benchmarked end to end (see "Further along" above), but going from the Dashboard to Add New Post has not.
  2. Repeat logins with a warm cache, where both configurations should converge.
  3. Anything other than Chrome, HTTP/1.1 and localhost. Over HTTP/2 the request-count effects shrink, which should narrow the gap between the arms. In Safari, the polyfill's cache reuse has been verified, but its timing has not been benchmarked.

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 before login_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, not preload. 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. as is 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 prefetch allows, since wp-login.php enqueues user-profile, and with it jquery, 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-admin is an alias handle enqueued on every admin screen that pulls in dashboard, edit, themes, nav-menus, widgets, revisions and 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-health was the sole exception and was dropped.

The gate is a prediction, not a reading. $concatenate_scripts cannot be relied on from the login screen: if script_concat_settings() runs before login_init fires — registering a script on init is enough to trigger it — it evaluates is_admin() as false and settles the global on false whatever the constant says, and the login screen then does not concatenate either. The login screen gates on wp_should_concatenate_admin_scripts() instead. That prediction can be wrong if a plugin pre-sets the global or defines the constant only when is_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, so admin_head reads it directly, and a plugin that sets it is respected there.

Known gaps

  • From the login screen, the editor's post type is taken from redirect_to, and an edit link to post.php does 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.
  • The block editor check is use_block_editor_for_post_type(). The Classic Editor plugin's mode that lets users switch editors filters use_block_editor_for_post instead, 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.
  • The login screen cannot check capabilities either, since no user is authenticated yet; it relies on redirect_to alone.
  • redirect_to is not resolved exactly as wp-login.php will resolve it after the login:
    • For a user whose profile asks for SSL, wp-login.php rewrites an http:// redirect_to containing wp-admin to https://. The user is not known yet, so the login screen prefetches the http:// URLs, which that admin will not reuse.
    • The login_redirect filter 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.
    • A redirect_to with . 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 a redirect_to built from the request URI, as auth_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.
  • The admin color scheme (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.
  • The login screen builds its URLs in the site's locale, and the admin uses the user's. On a site whose locale is right-to-left with a user whose locale is left-to-right, or the other way around, the login screen prefetches the stylesheets of the wrong text direction, and none of them are reused.
  • Footer scripts are not prefetched, per "Render-blocking only" above. Getting their DOMContentLoaded gain without the slow-connection LCP cost would need the prefetch to be abandoned when the login form is submitted. The Safari polyfill's 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-Data is 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 methods do_item() uses, which fixes the args/#fragment handling (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_WpPrefetchAdminAssets covers wp_prefetch_admin_assets():
    • what the login screen prefetches, on every one of its screens and at a custom login URL, depending on 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 over http and https
    • which post types the login screen and the admin prefetch the editor's stylesheets for, including those that use the classic editor
    • what the Dashboard and post list tables prefetch for the editor, and for which users
    • skipping handles the current screen has printed or queued, along with their dependencies, including the login form's own jQuery with the callbacks default-filters.php registers, and right-to-left stylesheets
    • skipping handles the next screen would print nothing for: those with conditional data, and a style whose URL is filtered away, along with its right-to-left stylesheet
    • that the clean_url filter runs once for each prefetched URL
    • that the login screen is recognized from whichever hook the function runs on, and that nothing is printed on a request with neither a login screen nor an admin screen
    • how the filter's result is deduplicated and sanitized, including dropping a URL esc_url() rejects and one with a scheme other than http or https
    • that the Safari polyfill is printed once, after all the prefetch links, and that nothing is printed when prefetching is turned off
    • that the login screen predicts concatenation rather than reading the $concatenate_scripts global, while admin screens read it
  • The polyfill's behavior in Safari is not covered by PHPUnit, which cannot run it; it was verified manually, as described in this comment.
  • These tests turn concatenation off with the wp_should_concatenate_admin_scripts filter, so they all run in the main process (about 0.5 s for 68 tests, down from about 37 s when each defined CONCATENATE_SCRIPTS in a separate process).
  • Tests_Dependencies_WpShouldConcatenateAdminScripts covers the new function: the constants, the filter, and script_concat_settings() taking its default from it. Only the two tests that define CONCATENATE_SCRIPTS run in a separate process.
  • Tests_Dependencies_WpScripts_GetSrc, Tests_Dependencies_WpStyles_GetSrc and Tests_Dependencies_WpStyles_GetRtlSrc cover the three new methods, including that each returns the URL do_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 the clean_url filter 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 a null version, which opts out of ?ver=, still got the default version on its right-to-left stylesheet, and arguments added to the handle, as with wp_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 to build_src(), as get_src() does, and since do_item() prints the stylesheet with it, the printed tag is fixed too. The style_loader_src filter is still passed the handle with -rtl appended, 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 conditional to add_data() named the method WP_Dependencies->add_data(), the only -> among the methods named in core's _deprecated_*() and _doing_it_wrong() calls. It is now WP_Dependencies::add_data(), and the @expectedDeprecated annotations 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_SCRIPTS false and SCRIPT_DEBUG false, and the block editor enabled for posts and pages, view source and count <link rel="prefetch"> tags:

Screen Expected
wp-login.php, ?interim-login=1 19
wp-login.php?action=lostpassword, ?checkemail=confirm (these do not load jQuery themselves) 21
wp-login.php?redirect_to=%2Fwp-admin%2Fpost-new.php, …post.php%3Fpost%3D1%26action%3Dedit, ?redirect_to=wp-admin%2Fpost-new.php 37
wp-login.php?redirect_to=%2Fwp-admin%2Fpost.php%3Fpost%3D1%26action%3Dtrash, ?redirect_to=%2Fwp-admin 19
wp-login.php?redirect_to=%2Fhello-world%2F, ?redirect_to=example.org%2Fwp-admin%2F 0
wp-login.php over http, with redirect_to set to this site's https://…/wp-admin/ 0
Dashboard, Posts list, Pages list 18
post-new.php, Plugins, Settings, Media 0
Anything, with CONCATENATE_SCRIPTS true 0

With the Classic Editor plugin active in its default mode, the Dashboard and list tables print 0, and a login redirecting to post-new.php prints 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.

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>
@manzoorwanijk

Copy link
Copy Markdown
Member

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, LOCAL_DIR=build, minified assets, SCRIPT_DEBUG=false) with the Chrome DevTools MCP server.

Summary

  • Cold loads (cache bypassed) on a throttled connection are where concatenation matters: removing it makes Dashboard FCP/LCP go from 616 ms to 1304 ms with gzip on (+688 ms, +112%) and from 1296 ms to 1484 ms with gzip off (+188 ms, +15%).
  • With a primed browser cache the difference is noise: +12 ms (+5%) with gzip on, within a few ms with gzip off.
  • Unthrottled on localhost, concat off is marginally faster (160 ms vs 172 ms), i.e. the PHP cost of load-styles.php / load-scripts.php outweighs the request savings when latency is near zero.
  • The whole effect comes from CSS: concat collapses 26 render-blocking stylesheets into one load-styles.php request. Scripts are barely concatenated on the Dashboard (only 6 handles in 2 load-scripts.php bundles; the other ~80 scripts load individually in both modes because they carry inline data or translations).
  • FCP and LCP were identical (or within 30 ms) in every run; the LCP element is the "Welcome" H2 heading, so both metrics are gated by render-blocking CSS.

Results

Fast 4G is Chrome DevTools' built-in preset applied via CDP Network.emulateNetworkConditions. N = 10 recorded runs per cell after 2 unrecorded warm-ups. IQR is p25 to p75.

Throttle gzip Cache Concat N Median FCP (ms) FCP IQR Median LCP (ms) LCP IQR Requests Transfer
Fast 4G on bypassed on 10 616 612 to 619 616 612 to 619 89 1354 KB
Fast 4G on bypassed off 10 1304 1300 to 1307 1304 1300 to 1307 117 1375 KB
Fast 4G on primed on 10 252 248 to 260 252 248 to 260 90 1 KB
Fast 4G on primed off 10 264 261 to 264 264 261 to 264 118 1 KB
Fast 4G off bypassed on 10 1296 1293 to 1296 1296 1293 to 1296 89 4360 KB
Fast 4G off bypassed off 10 1484 1481 to 1487 1484 1481 to 1487 117 4369 KB
Fast 4G off primed on 10 260 256 to 260 276 273 to 280 90 3 KB
Fast 4G off primed off 10 256 256 to 260 282 280 to 284 118 3 KB
None on bypassed on 10 172 172 to 175 172 172 to 175 89 1354 KB
None on bypassed off 10 160 153 to 163 160 153 to 163 117 1375 KB

Delta of "concat off" versus "concat on" (positive = removing concat is slower):

Throttle gzip Cache FCP on FCP off Delta ms Delta % LCP on LCP off Delta ms Delta %
Fast 4G on bypassed 616 1304 +688 +112% 616 1304 +688 +112%
Fast 4G on primed 252 264 +12 +5% 252 264 +12 +5%
Fast 4G off bypassed 1296 1484 +188 +15% 1296 1484 +188 +15%
Fast 4G off primed 260 256 -4 -2% 276 282 +6 +2%
None on bypassed 172 160 -12 -7% 172 160 -12 -7%

Request shape per condition (from the network log):

  • Concat on: 3 stylesheet requests (1 load-styles.php bundling 26 handles, thickbox.css, colors.min.css) plus editor.min.css, and 2 load-scripts.php bundles (jquery-core,jquery-migrate,utils and hoverIntent,wp-dom-ready,wp-hooks) plus ~80 individual scripts.
  • Concat off: 28 stylesheet requests and 84 script requests, all individual files.
  • Transferred bytes are essentially the same in both modes (1354 vs 1375 KB gzipped, 4360 vs 4369 KB raw); the difference is request count and HTTP/1.1 connection queuing, not payload.

Raw per-run values (ms)

  • Fast 4G, gzip on, bypassed, concat on: FCP 620, 612, 612, 616, 624, 616, 612, 616, 620, 612; LCP same as FCP
  • Fast 4G, gzip on, bypassed, concat off: FCP 1304, 1296, 1304, 1300, 1308, 1312, 1300, 1300, 1304, 1308; LCP same as FCP
  • Fast 4G, gzip on, primed, concat on: FCP 248, 260, 248, 260, 252, 268, 340, 252, 244, 248; LCP same as FCP
  • Fast 4G, gzip on, primed, concat off: FCP 264, 252, 264, 264, 268, 264, 264, 260, 264, 256; LCP same as FCP
  • Fast 4G, gzip off, bypassed, concat on: FCP 1296, 1296, 1288, 1296, 1292, 1300, 1292, 1296, 1300, 1296; LCP same as FCP
  • Fast 4G, gzip off, bypassed, concat off: FCP 1484, 1484, 1488, 1480, 1484, 1480, 1476, 1484, 1488, 1488; LCP same as FCP
  • Fast 4G, gzip off, primed, concat on: FCP 260, 264, 260, 256, 256, 260, 256, 260, 260, 256; LCP 276, 280, 284, 272, 272, 276, 280, 276, 288, 272
  • Fast 4G, gzip off, primed, concat off: FCP 256, 260, 264, 256, 260, 256, 252, 256, 260, 256; LCP 272, 284, 280, 280, 284, 284, 280, 284, 284, 280
  • Unthrottled, gzip on, bypassed, concat on: FCP 172, 168, 168, 176, 172, 172, 176, 172, 172, 196; LCP same as FCP
  • Unthrottled, gzip on, bypassed, concat off: FCP 164, 164, 152, 160, 156, 160, 160, 148, 152, 164; LCP same as FCP

Method

  • Fixture: trunk at 3150f656e2 (includes the gzip toggle from PR Build/Test Tools: Enable text compression in the local Docker environment #12529), npm run build, .env with LOCAL_DIR=build and LOCAL_NGINX_COMPRESSION=on|off, env restarted on every gzip switch and verified with curl -I (Content-Encoding: gzip present or absent).
  • Toggle: wp-config.php defines SCRIPT_DEBUG and CONCATENATE_SCRIPTS from $_GET['script_debug'] / $_GET['enable_concat'] (exact true/false strings; defaults false/true), sets WP_DEBUG_DISPLAY false and DISABLE_WP_CRON true. URLs measured: /wp-admin/?enable_concat=true&script_debug=false and /wp-admin/?enable_concat=false&script_debug=false.
  • Static asset caching: the local nginx template got a location ~* \.(js|css)$ { expires 1y; add_header Cache-Control "public"; } block so individual assets are cacheable like on production hosts (otherwise the primed comparison is unfairly tilted toward load-*.php, which sends its own 1 year max-age).
  • Browser: dedicated isolated Chrome context, logged in as admin, viewport 1440x900, no extensions, tab in foreground, no interaction during loads.
  • Cache bypassed: CDP hard reload (Page.reload with ignoreCache), which refetches every subresource (verified: 114 of 117 resources with non-zero transferSize each run). Primed: one priming load, then soft reloads; verified all subresources served from cache (transferSize 0).
  • Metrics: read in-page after load + 1.5 s settle via performance.getEntriesByName('first-contentful-paint') and a buffered largest-contentful-paint PerformanceObserver (last entry), recorded per run, median and IQR computed offline. Raw JSON per cell is in the session scratchpad results/ folder.
  • Preflight per condition confirmed the presence/absence of load-scripts.php / load-styles.php, request counts, .min assets and Content-Encoding.

Caveats

  • Run-to-run spread is tiny (IQR 3 to 8 ms) because DevTools throttling is a deterministic simulation on top of a localhost server. Real networks will be noisier; the direction and magnitude of the cold-load penalty on HTTP/1.1 is what to take from this, not the exact ms.
  • "Cache bypassed" reuses warm TCP connections across reloads and is a repeated-navigation scenario, not a true first visit (no DNS/TCP handshake cost is included). A real first visit would make the concat-off penalty on HTTP/1.1 slightly larger.
  • Only the Dashboard was measured. Screens with larger CSS/JS bundles (post editor: 36 style handles, customizer) will show a bigger cold-load gap.
  • Chrome's Fast 4G preset values are what the current Chrome build ships; the observed document TTFB under throttling was ~100 ms.

Interpretation for the removal decision

  • Removing concatenation with no replacement is a clear regression for cold loads on HTTP/1.1: roughly 2x FCP/LCP on the Dashboard with gzip, ~15% without gzip. Returning visitors with a warm cache are unaffected.
  • The regression is entirely about the number of render-blocking CSS requests. Any replacement only needs to address CSS delivery on first load (fewer stylesheet requests, preload, or 103 Early Hints); the script side is already effectively unconcatenated on this screen.
  • HTTP/2 and HTTP/3 (phase 2) should shrink this gap substantially since request multiplexing removes the 6-connection HTTP/1.1 limit; that measurement is the next step before deciding.

Environment state left in place (revert notes)

These local changes are still applied so phase 2 can continue; none are committed:

  • .env: LOCAL_DIR=build, LOCAL_NGINX_COMPRESSION=on (was src, unset). Revert and npm run env:restart to get back to the normal dev setup.
  • wp-config.php: SCRIPT_DEBUG / CONCATENATE_SCRIPTS query toggles, WP_DEBUG_DISPLAY false, DISABLE_WP_CRON true (was SCRIPT_DEBUG true, WP_DEBUG_DISPLAY true).
  • tools/local-env/default.template: the expires 1y block for .js/.css (shows in git diff).
  • concat-perf-strategy.md and this file are untracked in the repo root.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Comment thread src/wp-includes/default-filters.php Outdated
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 );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/wp-includes/script-loader.php Outdated
@manzoorwanijk

manzoorwanijk commented Aug 16, 2026 •

Copy link
Copy Markdown
Member

Combined review of Claude and Codex

Preload the admin's unconcatenated assets from the login screen

Reviewed against concat-perf-findings.md and concat-perf-strategy.md (HTTP/1.1, Dashboard, Fast 4G, local Docker env, LOCAL_DIR=build, gzip on). The PR branch was checked out and its two PHP files were copied into build/ for testing; the copies were reverted afterwards. Codex reviewed the same diff independently; its findings are folded in and attributed below.

Verdict

The 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 wp-login.php, uses rel="preload" for a next-navigation resource (which Chrome does warn about, contrary to the PR description), and pushes 25 speculative requests onto every login-family screen. Worth continuing as a complementary optimization if the handle list is derived rather than hardcoded and the semantics are switched to prefetch, but it should not be the argument for retiring load-styles.php.

What was verified

  • Handle lists match exactly what concatenation produces on the Dashboard today: the 25 style handles are the two load-styles.php chunks (wp-pointer is split across the chunk boundary, which is why the findings doc counted 26) and the 6 script handles are the two load-scripts.php bundles.
  • With CONCATENATE_SCRIPTS=false, SCRIPT_DEBUG=false: 25 <link rel='preload'> tags after the 7 login stylesheets, none with concat on. All 25 URLs match Dashboard request URLs byte for byte, and after login all 31 preloaded assets (including the 6 shared with login) are served with transferSize 0.
  • Effect on the first Dashboard load after login, Fast 4G, cold cache, concat off: FCP 1128 ms (trunk) vs 572 ms (PR). For reference the findings doc has concat on at 616 ms and concat off at 1304 ms on a hard reload, so the PR recovers the whole FCP gap for this one path. load went 3738 ms to 3052 ms; 41 vs 20 of 118 resources from cache.
  • Cost on the login screen itself, Fast 4G, cold cache: FCP 572 ms (trunk) vs 580 ms (PR), so render is not hurt. load event moved from 900 ms to 1475 ms and resource count 23 to 44. Extra transfer is 129 KB gzipped (557 KB raw), about 2.6x the login page's own CSS payload.
  • Chrome logs 21 "was preloaded using link preload but not used within a few seconds from the window's load event" warnings on the login screen (every preload except jquery-core, jquery-migrate, wp-dom-ready, wp-hooks, which the login page consumes itself). The PR description says no such warnings appeared; that is not what a default install shows.

Findings

Design

  1. Scope of the win is narrow. It only helps a cold cache reached via wp-login.php. Cold-cache admin loads that never see the login screen are common and arguably more common for logged-in users: every core update changes every ver string, plugin updates change theirs, and remember-me cookies last 14 days. Those paths still pay the full concat-off penalty from the findings doc (+688 ms FCP on HTTP/1.1 with gzip). The PR should be framed as a complement, not a replacement.
  2. preload vs prefetch. The resources are for the next navigation, which is what prefetch means. Consequences of using preload here: unused-preload console warnings on every login page load (verified), preload priority semantics rather than idle-time semantics, and cross-navigation reuse depends entirely on the static files' HTTP cache headers, which core does not control. On hosts with no Cache-Control/Expires on .css/.js, freshly deployed files get short heuristic freshness and the Dashboard will revalidate each one (304 per file, still one HTTP/1.1 round trip each). Chrome keeps prefetch responses reusable for 5 minutes regardless of cacheability, which fits this use case better. This needs a browser-by-browser check either way (Codex raises the same point).
  3. Runs on every login_head, not just the login form: lostpassword, resetpass, register, logout confirmation, interim-login, failed logins. Speculatively fetching 129 KB gzipped for a user who lands on "check your email" is wasted, and Save-Data is not honored (Codex).
  4. Scripts are not worth preloading. Both the findings doc and the PR's own numbers show the gap is CSS. Dropping the 6 script handles removes the custom script URL resolver (and finding 6 below) for negligible loss.
  5. Hardcoded list will drift. Nothing ties it to what WP_Styles::do_item() actually concatenates. site-health is capability-gated on the Dashboard, admin-bar can be disabled, wp-auth-check can be filtered off (Codex). A more robust shape: register the list next to the wp-admin alias in wp_default_styles(), or derive it from the wp-admin handle's dependency tree plus a small explicit set, and add a test that compares it against the concat output on the Dashboard.
  6. Locale mismatch. The login screen resolves URLs with the site (or wp_lang) locale; the admin uses the user locale. An RTL user on an LTR site preloads the LTR files and then loads the RTL ones. Edge case, but it is silent waste.

Correctness in _wp_resolve_dependency_urls()

  1. Script branch drops $dependencies->args[ $handle ] and the #fragment handling that WP_Scripts::do_item() has (class-wp-scripts.php around the $added_args block). No core handle in the list uses either, but the helper claims to mirror do_item() and a plugin passing handle?arg would preload a URL that never gets requested. Goes away if scripts are dropped (finding 4).
  2. script_loader_src / style_loader_src run in a logged-out, non-admin request. Filters that branch on is_admin(), screen or user (CDN rewriters, per-user asset URLs) can produce a URL that differs from the one the admin request will load, defeating the cache hit. Also 31 extra filter invocations on an unauthenticated page (Codex). Worth a sentence in the docblock at least.
  3. Gate: script_concat_settings() honors a pre-set $concatenate_scripts global and plugins can define CONCATENATE_SCRIPTS only when is_admin(); the constant-only prediction can be wrong in both directions (Codex). Acceptable given the login-screen quirk the PR describes, but the docblock should say the gate is a prediction.
  4. Double escaping is harmless: _css_href() already returns esc_url() output and esc_url() is idempotent (verified), but the RTL branch string-replaces on an escaped URL exactly like do_item() does, so it is at least consistent.
  5. Filter contract: the docblock says the login_preload_admin_assets filter takes the same shape as wp_preload_resources, but the printer only emits href, as, fetchpriority; crossorigin, type, media are dropped (Codex). Either print the same attribute set as wp_preload_resources() or narrow the docblock.
  6. Not idempotent: calling wp_preload_admin_assets() twice prints the tags twice (Codex). Minor.

Verified as fine

  • Hook priority 10 after print_admin_styles/wp_print_head_scripts at 9, so the done check correctly skips the 6 login-shared handles.
  • RTL replace/append logic matches WP_Styles::do_item(); core admin styles all use replace with a suffix.
  • Escaping of filtered output (esc_url, esc_attr, type checks) is fine.
  • Login FCP is not regressed by the extra requests on Chrome with fetchpriority="low" (measured +8 ms).

Suggested direction

  • Keep the idea, switch to rel="prefetch" (or measure both across Chrome, Firefox, Safari with and without static cache headers before deciding).
  • Styles only; drop the script preloads and the script resolver.
  • Restrict to the login form action, skip Save-Data, skip interim-login.
  • Derive the handle list or add a test that fails when it drifts from the Dashboard concat output.
  • Re-benchmark the login screen on Fast 3G / mobile, since the cost is bandwidth rather than render.
  • Do not use this as the basis for removing concatenation; the phase 2 HTTP/2 and HTTP/3 measurement in the strategy doc is still the missing input.

Codex review (verbatim summary of its verdict)

"Overall verdict: not a sound approach. The URL resolver has fixable correctness gaps, but the larger problem is architectural: preload is being used for a future navigation and forces a sizable speculative download on every login-family screen. A CSS-only concatenation strategy, or admin-response Early Hints where supported, is substantially safer."

Where I differ from Codex: it rated the login-screen cost as blocking; measured, login FCP is unaffected and the cost is bandwidth plus a later load event, so I would rate it important rather than blocking. Its wp_installing() point does not apply (the login screen is not shown during install). Its architectural point stands. Full Codex output: session 01a00c8d-755c-7f70-bd92-98cecb0b185c (codex resume 01a00c8d-755c-7f70-bd92-98cecb0b185c).

@haqadn

haqadn commented Aug 16, 2026

Copy link
Copy Markdown
image

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Claude analysis of benefit of preloading

Dashboard load after logging in (10 runs per arm, fresh cache each run)

Metric No preload (control) With preload Δ
FCP 1256 ms 714 ms −542 ms (−43%)
LCP 1256 ms 714 ms −542 ms (−43%)
DOMContentLoaded 3880 ms 3252 ms −628 ms (−16%)
Load event 4096 ms 3466 ms −630 ms (−15%)
Login POST + 302 291 ms 293 ms +2 ms
Requests 123 123 0
Served from cache 21 42 +21
Transferred 1366.9 KB 1271.9 KB −95.0 KB

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:

Scenario FCP
Concat ON, cold, direct nav (earlier) 740 ms
Concat OFF + preload, redirect-adjusted 421 ms
Concat OFF, no preload, redirect-adjusted 965 ms
Concat OFF, cold, direct nav (earlier) 1342 ms

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

Metric No preload With preload Δ
Login FCP 570 ms 568 ms −2 ms
Login load event 908 ms 1597 ms +689 ms
Last preload finished — 1570 ms —

fetchpriority="low" is doing its job: the login screen's own paint is untouched. What does move is the load event, +689 ms, because it waits on the preloads. Worth knowing if anything hooks window.onload there.

Two caveats on reading this

Why 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.

westonruter and others added 3 commits August 16, 2026 15:28
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>
@westonruter westonruter changed the title Script Loader: Preload the admin's unconcatenated assets from the login screen Script Loader: Pfetch the admin's unconcatenated assets from the login screen Aug 16, 2026
@westonruter westonruter changed the title Script Loader: Pfetch the admin's unconcatenated assets from the login screen Script Loader: Prefetch the admin's unconcatenated assets from the login screen Aug 16, 2026
Comment thread src/wp-includes/script-loader.php Outdated
westonruter and others added 2 commits August 16, 2026 16:14
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>
@westonruter westonruter changed the title Script Loader: Prefetch the admin's unconcatenated assets from the login screen Script Loader: Prefetch assets for the next admin screen Aug 18, 2026
westonruter and others added 10 commits August 18, 2026 13:56
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>
Comment thread src/wp-includes/script-loader.php Outdated
Comment thread src/wp-includes/script-loader.php Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

URL validation and RTL filtering issues can produce unusable prefetches or altered stylesheet URLs.

Review effort: Balanced
Findings: 3 Medium severity · 1 Low severity

Open (4)
Resolved since last review (1)

Comment thread src/wp-includes/class-wp-styles.php
Comment thread src/wp-includes/script-loader.php Outdated
Comment thread src/wp-includes/script-loader.php
Comment thread tests/phpunit/tests/dependencies/wpShouldConcatenateAdminScripts.php Outdated
westonruter and others added 14 commits October 1, 2026 23:58
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>
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>
@westonruter

Copy link
Copy Markdown
Member Author

Pushing up commits from rounds of high-effort reviews from Opus 5.5.

westonruter and others added 14 commits October 2, 2026 23:33
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>
`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 `&amp;` 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants