Skip to content

Script Loader: Disable script and style concatenation by default - #13090

Draft
haqadn wants to merge 9 commits into
WordPress:trunkfrom
haqadn:57548-disable-concatenation-by-default
Draft

haqadn wants to merge 9 commits into
WordPress:trunkfrom
haqadn:57548-disable-concatenation-by-default

Conversation

@haqadn

@haqadn haqadn commented Aug 16, 2026 •

Copy link
Copy Markdown

Trac ticket: https://core.trac.wordpress.org/ticket/57548

Flips the fallback in wp_should_concatenate_admin_scripts() so an undefined CONCATENATE_SCRIPTS resolves to false instead of true. load-scripts.php and load-styles.php become opt-in — sites that want the current behavior can define( 'CONCATENATE_SCRIPTS', true ).

Since r64120, wp_should_concatenate_admin_scripts() holds the default that script_concat_settings() gives the $concatenate_scripts global, and it is also what the login screen's prefetching uses to predict whether the admin will concatenate. Flipping the default there changes both at once, so they cannot disagree. (This PR originally flipped it inline in script_concat_settings(); the merge with trunk after r64120 moved the change into the new function, along with the function's docs and its filter's @param, which state the default, and updated the tests of the default.)

Scope is narrower than the constant's name suggests. script_concat_settings() only applies this default on admin screens and the login screen, and leaves the global false everywhere else, so only wp-admin and the login screen change. The concatenation code paths themselves are untouched.

Separately, TinyMCE is now always registered as separate files instead of the prebuilt wp-tinymce.js bundle, whether or not scripts are concatenated. See "Notes about TinyMCE" below.

Two reasons to make this the default:

  • Concatenation is the root of a class of bugs that only appear when it is on. SCRIPT_DEBUG being ignored (#52879), translations not printing for concatenated handles (#50999), and "use strict" leaking between bundled scripts (#65515) are all conditions a stock install currently ships into. Defaulting off takes wp-admin off that path.
  • It is what makes r64120 do anything. That change (Script Loader: Prefetch assets for the next admin screen #13084) prefetches the admin's individual assets from the login screen and the editor's stylesheets from the Dashboard and post lists, but only when concatenation is off. Under today's default it is inert on a stock install; with this flip it applies by default, which is the pairing the two changes were intended to have.

Performance

The cold-cache performance trade-off was the open question when this PR was opened. The benchmarks on #13084 answer it for the main path into the admin: logging in with an empty browser cache. They compare today's default (concatenation on) with this PR's configuration (concatenation off, with r64120's prefetching), and with concatenation off and nothing prefetched as a control. Medians, with prefetching relative to today's default:

Fast 4G Slow 4G
Dashboard LCP −25% −47% (1.86 s vs 3.49 s)
Dashboard DOMContentLoaded +2% 0%
Posts list FCP −58% −70%
Block editor FCP −44% −70%
Block editor ready (post title visible) −11% −15%
Login screen FCP +11% −5%
Login screen load +32% +32%

Full method and raw numbers: login → Dashboard (10 runs per arm) and login → Dashboard → Posts list → block editor (3 runs per arm).

Turning concatenation off with prefetching is faster than today's default because a concatenated bundle is only reused when its exact list of handles matches. The Posts list and the editor each build bundles that differ from the Dashboard's, so they download the common admin CSS and jQuery again, while individual files carry over from screen to screen. The login screen's higher load time comes from concatenation being off there, not from the prefetching, and its FCP barely moves.

What the benchmarks do not cover:

  • Reaching the admin without passing through the login screen, e.g. with a "Remember me" cookie still valid after the browser cache was cleared. Nothing is prefetched in that case, and the cold Dashboard is slower than today: about 280–310 ms of LCP, and 0.5 s (Fast 4G) to 1.2–1.9 s (Slow 4G) of DOMContentLoaded. The Posts list is still faster without concatenation, and the editor is about even.
  • Repeat visits with a warm cache, where the configurations should converge. As @sgomes pointed out, the benefit of prefetching is mostly once per user, until the cache is evicted.
  • HTTP/2 and browsers other than Chrome. All runs were on one machine, over HTTP/1.1 on localhost with DevTools throttling. HTTP/2 should narrow the gaps in both directions, since request count matters less there.
  • A concatenated site prefetching its own bundles. Part of the lead comes from prefetching as such, not only from dropping concatenation.
  • Compression dictionaries, discussed on the ticket, which are not part of this comparison.

Notes about TinyMCE

TinyMCE is now always registered as separate files: TinyMCE core (wp-tinymce-root) and the compat3x plugin (wp-tinymce). When an editor is initialized, TinyMCE fetches its theme, plugins and skin itself. The prebuilt wp-tinymce.js bundle is no longer registered.

Until now, the bundle was registered only when scripts were both concatenated and compressed. Concatenation is now off by default. $compress_scripts hasn't compressed anything since r43580 and r44651, and it is slated for removal (Core-66053). So TinyMCE's registration no longer depends on either. The second parameter of wp_register_tinymce_scripts() is unused, so it is deprecated and renamed to $deprecated. A site that wants the bundle can still register it under the wp-tinymce handle:

add_action( 'wp_default_scripts', static function ( WP_Scripts $scripts ): void {
	$scripts->remove( array( 'wp-tinymce', 'wp-tinymce-root' ) );
	$scripts->add( 'wp-tinymce', includes_url( 'js/tinymce/wp-tinymce.js' ), array(), $GLOBALS['tinymce_version'] );
}, 11 );

The bundle holds TinyMCE core, the modern theme and all 22 plugins: 671 KB, versus 369 KB for core plus compat3x. Whether leaving it out helps depends on whether TinyMCE is initialized.

Loaded but not initialized: smaller and faster. The block editor loads TinyMCE on every screen. It initializes TinyMCE only when a Classic block's "Edit" button is clicked, even if the post already contains a Classic block. Block editor page load, before any click:

Fast 4G Slow 4G
TinyMCE transfer 219 → 125 KB 219 → 125 KB
DOMContentLoaded and load −73 ms −576 ms
FCP unchanged unchanged

Initialized: slower. This covers the classic editor, and a Classic block once "Edit" is clicked:

Fast 4G Slow 4G
Classic editor: TinyMCE ready 4.68 → 5.38 s (+15%) 17.60 → 19.87 s (+13%)
Classic editor: DOMContentLoaded −104 ms −567 ms
Classic editor: load +537 ms +1,845 ms
Classic editor: FCP unchanged unchanged
Classic block: from "Edit" click to ready 254 → 1,034 ms 896 → 3,682 ms

The bytes are about the same: 233 KB versus 228 KB of TinyMCE in the classic editor, over 20 more requests. The delay comes from the order the files load in. Once its core has run, TinyMCE requests the rest in three rounds, each waiting for the last:

  1. the theme;
  2. the 18 plugins the editor uses, in parallel;
  3. the skin.

With the bundle, the "Edit" click needs 2 requests (27 KB). Without it, the click needs 21 requests (127 KB).

TinyMCE is being phased out, so this cost is accepted. It could be reduced by registering the theme and plugins as their own handles and enqueuing them where the classic editor initializes TinyMCE. They would then load from the page's HTML instead of waiting for TinyMCE to request them. That would not help the Classic block, which only fetches them when "Edit" is clicked.

Method: medians of 5 cold-cache runs per arm, run alternately, with a real login each run. Chrome over HTTP/1.1 on localhost with DevTools throttling, concatenation and SCRIPT_DEBUG off, all plugins disabled. HTTP/2 should shrink the cost of the extra requests.

Testing instructions

  1. Use an install with neither CONCATENATE_SCRIPTS nor SCRIPT_DEBUG defined in wp-config.php.
  2. Load any wp-admin screen (the Dashboard is fine) and view source. There should be no requests to load-scripts.php or load-styles.php — every handle appears as its own <script src> or <link href>.
  3. Confirm the admin behaves normally: menus, the list tables, and the block editor should all load without console errors.
  4. In the block editor, add a Classic block and click "Edit". The editor should open and work, and the browser's Network panel should show tinymce.min.js and plugins/compat3x/plugin.min.js, followed by the modern theme and the editor plugins as separate requests, with no wp-tinymce.js. Do the same in the classic editor (e.g. with the Classic Editor plugin active), confirming the Visual tab works.
  5. Load wp-login.php and view source. There should be <link rel="prefetch"> tags for the admin's stylesheets and head scripts near the end of the page, since the login screen now predicts that the admin will not concatenate.
  6. Add define( 'CONCATENATE_SCRIPTS', true ); to wp-config.php and reload the same screens. Both load-scripts.php and load-styles.php should reappear on the admin screen, confirming the opt-in path still works, and the login screen should print no prefetch links. TinyMCE should still load as separate files, not as wp-tinymce.js.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5, Claude Opus 5.5
Used for: Making the change, running the test suite and confirming the pre-existing failures against baseline, and drafting this description. Reviewed by me.

The merge with trunk after r64120, moving the change into wp_should_concatenate_admin_scripts() and updating its tests and this description (including the performance summary from the #13084 benchmarks), was done by @westonruter with Claude Code (Claude Opus 5.5).

Always registering TinyMCE as separate files, deprecating the second parameter of wp_register_tinymce_scripts(), benchmarking TinyMCE with and without the bundle, and the matching updates to this description were also done by @westonruter with Claude Code (Claude Opus 5.5).

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

Flips the fallback in `script_concat_settings()` so an undefined
`CONCATENATE_SCRIPTS` resolves to `false` instead of `true`, making
`load-scripts.php` and `load-styles.php` opt-in. Sites that want the
previous behaviour can define the constant as `true`.

The concatenation code paths are unchanged, and the surrounding guard
already forces the global to `false` outside of `is_admin()` and
`login_init`, so only wp-admin and the login screen are affected.

See #57548.
westonruter and others added 8 commits October 6, 2026 00:39
Trunk moved the default for the `$concatenate_scripts` global out of `script_concat_settings()` and into `wp_should_concatenate_admin_scripts()`, which the login screen's prefetching also uses to predict whether the admin will concatenate. The conflict in `script_concat_settings()` is resolved to trunk's version, and this branch's change, making an undefined `CONCATENATE_SCRIPTS` mean `false` rather than `true`, is applied in `wp_should_concatenate_admin_scripts()` instead, along with the function's description and its filter's `@param`, which state the default. The prefetching therefore follows the new default too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fault

Two tests still expected an undefined `CONCATENATE_SCRIPTS` to mean concatenation unless `SCRIPT_DEBUG` is on. They passed when run from `src/`, where `SCRIPT_DEBUG` is on and turns concatenation off whatever the default, but would fail where it is off. They now expect no concatenation unless the constant is defined as true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TinyMCE was registered as the prebuilt `wp-tinymce.js` bundle only when scripts were both concatenated and compressed, and otherwise as TinyMCE core plus the compat3x plugin. Both conditions are on their way out: concatenation is now off by default, and `$compress_scripts` has not compressed anything since PHP compression was removed from the loaders in r43580 and from `wp-tinymce.php` in r44651. TinyMCE is now registered as the separate files regardless, which is how it is served on the front end and in the admin by default.

The bundle holds the modern theme and all 22 plugins, which TinyMCE otherwise loads itself only when an editor is initialized. The block editor loads TinyMCE on every screen but only initializes it for a Classic block, so most screens skip about 300 KB.

The `$force_uncompressed` parameter is now unused. The script_concat_settings() call stays, since this is where an admin screen settles the concatenation settings, as the `wp_should_concatenate_admin_scripts` filter documents. A site can still register the bundle under the `wp-tinymce` handle itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `$wp_scripts` global is typed as `mixed`, which PHPStan rejects as the `WP_Scripts` argument to wp_register_tinymce_scripts(). The wp_scripts() accessor returns a `WP_Scripts` instance, initializing the global first if needed.

The `true` passed as the second argument is dropped too, since that parameter is no longer used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since TinyMCE is always registered as separate files, the `$force_uncompressed` parameter has nothing left to force. It is renamed to `$deprecated` and typed with `@phpstan-param false`, as for other deprecated parameters, so that PHPStan reports any caller still passing `true`. Passing a truthy value also triggers _deprecated_argument(), which uses the parameter and so resolves its `function.unusedParameter` error.

_WP_Editors::force_uncompressed_tinymce() no longer passes `true`, so it does not trigger the notice.

The signature also gains a `void` return type, which resolves the `missingType.return` error reported on the changed line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deprecation notice in wp_register_tinymce_scripts() was triggered only for a non-empty `$deprecated`, so a caller passing `null`, `0` or an empty string went unnoticed. Since `false` is the only value the parameter accepts, as its `@phpstan-param` says, any other value now triggers the notice.

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.

2 participants