Problem/Motivation

There are several previous issues discussing this, but all several years old and mostly abandoned because we didn't have either a JavaScript build step or #1014086: Stampedes and cold cache performance issues with css/js aggregation in core. Should be added as related issues.

Postponed on #2258313: Add license information to aggregated assets which we need to fulfil GPL licensing requirements.

CSS minification has very different requirements and is discussed in #3302612: Consider adopting a third party CSS minification library.

There are broadly two approaches we could take:

1. Adopt a PHP JavaScript minifier/uglifier, and then use this to process aggregates. The disadvantages are more CPU/time building aggregates, and PHP not being the best language to process JavaScript.

2. Uglify/minify JavaScript assets via the build step to .min.js files (which get committed to the git repo), and then use those when building aggregates.

On-the-fly minification with JavaScript isn't doable without introducing a node dependency to core. Having some minification in core is a lot better than none, even if better theoretical methods exist, because only a small fraction of sites will install contrib modules (let alone server-side dependencies) purely to improve front-end performance.

Since we can ensure that all core JavaScript is minified via a build step, but can't do this for contributed modules, we can do both:

1. Minify core JavaScript to foo.js - the original will be at foo.es6.js or foo.source.js, and add {minified: true} to the library definition.

2. Add on-the-fly minification with Peast to any JavaScript that's not pre-minified.

Steps to reproduce

Proposed resolution

Add on-the-fly minification (but not uglification) to core, using Peast https://github.com/mck89/peast
This reduces ajax.es6.js from 59kb down to around 17kb in around 100ms. There is some performance cost to this, but we create aggregates in their own dedicated request and write them to disk, so any performance penalty only hits the request that actually generates an aggregate the first time, and even then it will take less time to transfer over the network post-minification. Additionally since core files will already be minified, the only additional time spent is for unminified contrib/custom JavaScript.

Dependency evaluation

  1. Maintained by Marco Marchio (https://github.com/mck89) with approximately nine other contributors. First stable release was in 2017
  2. Does not have a formal security policy. (request made in https://github.com/mck89/peast/issues/51)
  3. Release cycle: Patch releases every 2-4 months, minor releases every 1-2 years. Last release was May 2022.
  4. Follows the ECMAScript specification, so a proper AST JavaScript parser rather than simply a minifier. Includes general documentation beyond the README at https://github.com/mck89/peast/tree/master/doc. Decent looking test suite: https://github.com/mck89/peast/tree/master/test/Peast
  5. No other dependencies.

Remaining tasks

None.

User interface changes

None.

API changes

None.

Data model changes

None.

Release notes snippet

Drupal core's JavaScript aggregation removes comments and whitespace from JavaScript files resulting in a significant filesize reduction. Sites using contribution minification solutions may wish to re-evaluate whether they can rely on core aggregation instead.

Comments

catch created an issue. See original summary.

catch’s picture

catch’s picture

Title: JavaScript minification/uglification » [PP-1] JavaScript minification/uglification
Status: Active » Postponed
nod_’s picture

I think merely removing comments and extra spaces is going to be enough for a core solution. Changing variable names or using shorter constructs doesn't help as much as comment and spaces removal. A couple of examples tweaking some babel config (compact meanst no comments and no whitespace):

file original compact: true minified: true
drupal.js 20kb 4.6kb 4.4kb
ajax.js 59kb 17kb 17kb
tabledrag.js 53kb 23kb 23kb
displace.js 7.9kb 2.4kb 2.3kb
toolbar.js 11kb 4.2kb 4.1kb

Which means that using something like peast for parsing the JS and outputting it in the "compact" format will be sufficient. A few benchmarks would be necessary to see what's the fastest option as well.

It's plugable so contrib can provide a better solution if necessary. I'm thinking this is a good enough solution for most people.

catch’s picture

The numbers in #4 are great! If we can get something like that using peast agreed that'll be good enough for most people.

nod_’s picture

The disadvantages are more CPU/time building aggregates

This is what we do with CSS aggregates so JS might be a bit more tricky to handle but the principle is the same, we'll need to have a look at the actual cost of compacting JS vs. CSS.

catch’s picture

Now that it's not a blocking operation for HTML page requests, and that aggregates can be requested in parallel instead of created strictly in sequence, we've quite a bit of headroom, and those savings are a lot.

nod_’s picture

was curious about jquery ui files, here is a few samples (using terser as a minifier, so not a direct comparison with the above table)

file original compact: true minified: true
widget.js 20kb 13kb 8.5kb
focusable.js 2.3kb 1.5kb 991b
autocomplete.js 18kb 11kb 8.5kb
dialog.js 24kb 16kb 13kb
draggable.js 35kb 22kb 19kb
resizeable.js 30kb 22kb 19kb

Same table as in #4 with terser doing the minification, not babel:

file original compact: true minified: true
drupal.js 20kb 4.2kb 2.8kb
ajax.js 59kb 17kb 12kb
tabledrag.js 53kb 23kb 17kb
displace.js 7.9kb 2.2kb 1.4kb
toolbar.js 11kb 4.1kb 3.4kb
catch’s picture

StatusFileSize
new4.38 KB
new4.59 KB
new4.61 KB
new72.47 KB
new72.94 KB

This is postponed on #2258313: Add license information to aggregated assets but only from the point of view of GPL licensing, so I think it's worth a viable patch here regardless to see where we end up.

Used Peast per nod_'s suggestion above, we'll need to do a dependency evaluation, but it has everything we need and is easy to use.

I'm attaching a patch that just adds the minification (no line breaks, comments, or optional braces).

I'm also attaching a patch that I used for timing - via hrtime() as well as the output. Processor on my local machine is an 8th generation i5, nothing special. I tested the front page of a 10.1.x standard install logged in a user/1 - this has quite a lot of CSS and JavaScript on it (contextual, toolbar, bigpipe etc.)

Small files like touchevents-test.js take ~1-2ms to process each.

drupal.js and contextual.js take ~18ms each.

ajax.js is the worst at ~97ms.

This is roughly linear with the size of the original files - but note below, we already strip nearly all comments from these files via the js build step

308 touchevents-test.js
6.1K drupal.js
22K  ajax.js

Next: a comparison of aggregate file sizes.
Before:

294K js_J2HoPNZci1FJMi8qdC8t1uBJc1DJta4UfcVkLRWX7zc.js
87K js_J2HoPNZci1FJMi8qdC8t1uBJc1DJta4UfcVkLRWX7zc.js.gz
310 js_qEsgx3cFmLUDeVuvoopSZvsrJF0VpVJ69BQUIa9mOFI.js
235 js_qEsgx3cFmLUDeVuvoopSZvsrJF0VpVJ69BQUIa9mOFI.js.gz

After:

266K js_J2HoPNZci1FJMi8qdC8t1uBJc1DJta4UfcVkLRWX7zc.js
84K js_J2HoPNZci1FJMi8qdC8t1uBJc1DJta4UfcVkLRWX7zc.js.gz
162  js_qEsgx3cFmLUDeVuvoopSZvsrJF0VpVJ69BQUIa9mOFI.js
122  js_qEsgx3cFmLUDeVuvoopSZvsrJF0VpVJ69BQUIa9mOFI.js.gz

On first glance, 294k down to 266k doesn't look that great, but this is deceptive because the aggregate has jQuery and Underscore in it, both of which are pre-minified so not touched by this patch.

jquery.min.js is 88k, underscore is 20k - this is a non-exhaustive list of the pre-minified JavaScript in the aggregate, there's also tabbable, shepherd and others. I think nod_'s numbers above are probably better for actual comparisons.

Also, our own files already have comments stripped by the js build step.

ajax.js is 22k, ajax.es6.js is 59k

Running the same Peast process against ajax.js or ajax.es6.js reduces both to 17k (i.e. the end result should be identical despite very different savings).

Timing Peast compression of ajax.js vs. ajax.es6.js is within 10% of each other, likely because ignoring comments is cheap compared to parsing and formatting the actual code.

ajax.js 60.56
ajax.es6.js 64.89

This means the filesize savings will be more dramatic after #3278415: Remove usages of the JavaScript ES6 build step, the build step itself, and associated dev dependencies without much extra CPU time.

Finally, browser timings from chromium for loading aggregates (and modernizr) - this is on localhost, so the least-friendly test given a 'normal' request would involve network latency.

screenshot from chromium dev tools

screenshot from chromium dev tools

This shows about 114ms for aggregate generation with no minification, vs about 400ms for aggregate generation with it - i.e. approximately 300ms additional time - more or less matches the raw timings of 100ms + 20ms + 20ms + lots of 1-10ms from the PHP timing.

For the tiny aggregate with hardly anything in it, it took less time to serve which shows the minification is within margin of error when there's not much to minify - the 1 or 2ms that takes is dwarfed by even local network time.

So if we assume the build step is removed, we're looking at hundreds of kb of file size reduction, vs. 200-400ms extra time for asset generation in a relatively bad case of a full admin.

But also we've taken the asset generation out of the page request in #1014086: Stampedes and cold cache performance issues with css/js aggregation - so given CSS + JavaScript aggregates could be 50-150ms each to generate, and we might have five to ten of them, that's 200 to 800ms off the HTML page and time to first byte. That means even if we add some time for minification, we're still overall spending less linear time aggregating on cold starts than Drupal 6 to Drupal 9 was.

And then the aggregates are requested in parallel, so even if the JavaScript aggregate request takes 400ms instead of 115ms, the browser is going to be loading and parsing various CSS aggregates while that's happening. I think this is more than reasonable for the completely cold start case, and every request for an aggregate afterwards will get the smaller file size.

catch’s picture

Title: [PP-1] JavaScript minification/uglification » [PP-1] JavaScript minification
catch’s picture

Issue summary: View changes
catch’s picture

StatusFileSize
new707 bytes
new4.42 KB
catch’s picture

Issue summary: View changes
nod_’s picture

Less precise data, On the admin/content page: I'm going from a mobile lighthouse score of 95 (raw) to 97 (minified) with this patch (on an empty css/js folder). With cached files the scores are 96 (raw) and 98 (minified).

All in all it seems to me we're good to go ahead with this performance wise, even with the extra delay, thanks to the parallelization and size reduction it's still a performance improvement for the end-user.

Going with peast instead of another minifier will be useful when we'll want to handle JS modules in core, and automatically build library dependencies based on what is imported in the actual scripts. Since we'll have a tool that can parse JS files reliably and provide an AST we can query for informations and avoid having people declare their import in the script and in the drupal space.

xjm’s picture

StatusFileSize
new284 bytes
new284 bytes

Just fixing the dictionary so tests will run.

catch’s picture

StatusFileSize
new4.7 KB

Adding #12 and #15 together.

nod_’s picture

Status: Postponed » Needs work

I'll move to unpostpone this, the license issue appear to be much simpler than initially expected.

Good news that peast does the job. We might want to try jsminifier and a few other existing php minification libs out there to compare processing times, just to make sure peast it not overly slow compared to other libs.

wim leers’s picture

Priority: Normal » Major
Issue tags: +Needs issue summary update, +Needs change record, +10.1.0 release highlights

Holy 🐄 that is very little code! 🤯 Can't believe this is finally happening! 🥳

How is this Normal priority? 🙃 Bumped to Major.

We still need a dependency evaluation added to the issue summary. This also warrants a change record. And obviously this would be a release highlight!

There are 3 failures in Drupal\Tests\Core\Asset\JsOptimizerUnitTest::testOptimize() to still address … but they are failing for poor reasons: irrelevant whitespace was stripped.

wim leers’s picture

catch’s picture

StatusFileSize
new794 bytes

Per @nod_'s suggestion I thought I'd compare Peast and JsMinify, couldn't find anything called that, but found https://github.com/matthiasmullie/minify and tried that in case that was what he meant and also why not ;)

This is a bit different from Peast:

1. You can minify multiple files at a time if you want as well as telling them where to write to, including a gzip option. Our aggregation architecture currently doesn't allow for this, but it'd be an option for the future. One file at a time is fine too though.
2. It does CSS as well as JS.
3. It does some code shortening for both CSS and JS.

Tried it on ajax.es6.js since that's one of core's larger files, the results are actually a fair bit different:

matthiasmulli/minify
7.99
mck89/peast
104.57

That's more than ten times as fast - presumably because it's mostly running multiple regexps vs. converting to/from AST. This would make a noticeable difference to aggregate generation requests - say we have to minify 20 files and Peast takes an average of 20ms each, that's 400ms vs. more like 40ms for matthiasmulli/minify. Could also mean it's more prone to hard-to-fix bugs: https://github.com/matthiasmullie/minify/issues has a fair number of open reports.

Peast however results in a 200b smaller filesize, not much difference but could add up a few kb across all files in all aggregates.

ls -l /tmp/minify.js 
-rw-r--r-- 1 catch catch 17418 Aug 24 15:27 /tmp/minify.js

ls -l /tmp/peast.js 
-rw-r--r-- 1 catch catch 17204 Aug 24 15:27 /tmp/peast.js

Even though Peast isn't explicitly doing code shortening, it's possible the conversion to and from AST does some implicitly compared to core js code style, so that might be why.

Checked out packagist download stats:

+-------------+-----------------+-------------------+
| Package     | Total Downloads | Monthly Downloads |
+-------------+-----------------+-------------------+
| mck89/peast | 2777381         | 104602            |
+-------------+-----------------+-------------------+
| SUMMARY     | 2777381         | 104602            |
+-------------+-----------------+-------------------+
+-----------------------+-----------------+-------------------+
| Package               | Total Downloads | Monthly Downloads |
+-----------------------+-----------------+-------------------+
| matthiasmullie/minify | 8814552         | 296582            |
+-----------------------+-----------------+-------------------+
| SUMMARY               | 8814552         | 296582            |
+-----------------------+-----------------+-------------------+

Given the download stats depend on things like CI jobs etc., not really much in it - they've both got multiple contributors, dozens of forks, recent stable releases etc.

Uploading the (very basic, just run drush scr ./minify-timing.php) script I used to produce the files and do the timing. Need to install both libraries via composer first.

catch’s picture

StatusFileSize
new1.05 KB

@nod_ suggested trying JShrink in Slack, here's the results:

https://github.com/tedious/JShrink

tedjvm/jshrink
11.82
matthiasmulli/minify
8.37
mck89/peast
102.94
ls -l /tmp/jshrink.js 
-rw-r--r-- 1 catch catch 17609 Aug 24 18:19 /tmp/jshrink.js
+----------------+-----------------+-------------------+
| Package        | Total Downloads | Monthly Downloads |
+----------------+-----------------+-------------------+
| tedivm/jshrink | 18460829        | 348812            |
+----------------+-----------------+-------------------+
| SUMMARY        | 18460829        | 348812            |
+----------------+-----------------+-------------------+

Still nearly ten times as fast as Peast, slightly higher filesize again, but I think this one might be the right trade-off: explicitly mentions minification on the fly, has a security policy, very few open bug reports etc.

10ms to cut 50kb off a file is probably enough to improve performance even for the initial cache hit that generates the files just reducing that initial transfer.

wim leers’s picture

I think this means we need to re-assess why we'd want to use mck89/peast versus one of the two others. Is there a technical reason to do so?

Clearly performance-wise the two alternatives have a significant edge! 😮

catch’s picture

StatusFileSize
new8.08 KB
new5.92 KB

Switching to JShrink and fixing the test failures.

catch’s picture

Issue summary: View changes
Issue tags: -Needs issue summary update

@Wim Leers looking at the bug reports against matthiasmullie/minify it looks like at least theoretically it might end up introducing errors where Peast wouldn't.

However, the issue queue of JShrink is a fair bit healthier, it has regular releases, seems well supported etc., and it's still ten time faster than Peast, so I don't think there's a good reason to choose Peast instead. The only reason would be if we also wanted to use Peast's AST parsing for some other purpose to only have one dependency, but we're not trying to do that yet.

catch’s picture

uhhhhh

Checking core/tests/Drupal/Tests/Core/Asset/js_test_files/latin_9.js.optimized.js

FAILURE core/tests/Drupal/Tests/Core/Asset/js_test_files/latin_9.js.optimized.js does not have a corresponding core/tests/Drupal/Tests/Core/Asset/js_test_files/latin_9.js.optimized.es6.js

Might be blocked on #3278415: Remove usages of the JavaScript ES6 build step, the build step itself, and associated dev dependencies unless we workaround that.

nod_’s picture

Status: Needs work » Needs review

I think those ones should be removed from the validation, they're excluded from eslint.

I think because we never updated those files after we added the commit checks we didn't see that before (last update in 2015...)

longwave’s picture

Wow! This is looking very promising and the one-line code change is almost unbelievable.

+++ b/composer.json
@@ -12,7 +12,8 @@
+        "tedivm/jshrink": "^1.4"

This needs to go in core/composer.json as it's a runtime dependency of core itself.

longwave’s picture

Having said that I'm not sure we can ship with JShrink with issues like these around regular expressions:

https://github.com/tedious/JShrink/issues/94
https://github.com/tedious/JShrink/issues/95

These could cause bugs in clientside JS that would be very hard to track down.

3. Release cycle: Patch releases every few months, minor releases every couple of years. Last release was for PHP 8.0 compatibility.

I disagree with this. While there has been a PHP 8 compatibility release, the minifier code itself in JShrink hasn't been touched for three years now.

mfb’s picture

I'm getting strong "now they have two problems" vibes here, as far as trying to use regex to minify JS.

Peast certainly sounds like it's on a more solid technical foundation - I am assuming that front-end assets are being built pretty rarely, so correctness is more important than speed. Is there any reason not to use JS tooling to build front-end assets? That way, a known-good parser could be used to transform JS, and source maps could be built too for easier debugging. Core could distribute the source JS, minified JS and source maps.

catch’s picture

Status: Needs review » Needs work

@longwave thanks for finding those two issues against JShrink, if there was the infrequent code changes without those issues that'd be a sign it's just working great, but instead it puts it in the same bucket as matthiasmulli/minify then, so reckon we're back to Peast.

@mfb

Is there any reason not to use JS tooling to build front-end assets? That way, a known-good parser could be used to transform JS, and source maps could be built too for easier debugging. Core could distribute the source JS, minified JS and source maps.

@mfb this was discussed a lot in this 15 year old issue: #119441: Compress JS aggregation where we ended just giving up. Things have moved on a bit since then, but not really that much in the scheme of things except that a lot of the groundwork for both approaches has been done in the meantime.

There are trade-offs in both directions:

1. We could repurpose the build step that's just about to be removed in #3278415: Remove usages of the JavaScript ES6 build step, the build step itself, and associated dev dependencies, to fully minify the foo.js files and keep the foo.es6.js files in place (or rename them to foo.source.js or something). We can't do foo.js/foo.min.js without updating all the library definitions (and breaking library alters etc.).

- minification done by js (very good)
- no performance implications from minification on the fly (good)
- keep the build step (probably fine?)
- no change for js from custom or contrib modules unless they also run the build step and switch to the same file structure. This means more work for contrib and less actual front-end performance impact for real sites unless it's done. (not great)

2. This issue: use JShrink or Peast to minify on the fly.

- minification in PHP (historically bad, but if Peast is still viable then fine)
- performance impact from minification on the fly (manageable even with Peast, but it's definitely there)
- no build step (about to remove it anyway)
- js from custom and contrib modules also gets minified (very good)

The last reason for doing minification on the fly would be if we were to adopt a much more aggressive minification that shortens variable names etc. - since if multiple files are using the same variable name, they could all use the same shortened version, saving even more space. However, the benefits of doing that are very small compared to just removing whitespace and none of the current approaches even begin to try to do that, so I think we can just ignore that for now and assume whatever we do it will be file by file.

So it really comes down to whether contrib/custom js gets minification 'for free' because core's doing it on the fly, vs. using js to minify core's js and hoping contrib follows suit.

The big difference between now and ten+ years ago is that all of the groundwork (apart from licensing which is close) is in place. It's only a few minutes to move the current patch back to Peast so I'll do that to get something working. I think it's probably viable to do minification in the build step (instead of removing the build step altogether), but mostly concerned that we do one of them instead of it taking another 15 years.

catch’s picture

Issue tags: +Needs issue summary update
StatusFileSize
new9.56 KB
new8.78 KB

Back to Peast. Need to re-do the issue summary update for the dependency evaluation.

nod_’s picture

@mfb the issue is that we want to avoid requiring nodejs for running Drupal in production. Also the dependencies for Js tools are massive and that would become a security concern pretty fast.
For source maps see #3227125: Produce a sourcemap for aggregated JS doable in PHP fairly easily

catch’s picture

@nod_ I think mfb's suggestion is to do minification in the build step, not on-the-fly something like this:

- Instead of shipping with ajax.js and ajax.es6.js, we'd ship with ajax.js (minified), and ajax.source.js (ajax.es6.js renamed).

- library definitions would continue to point to ajax.js, since that's minified, and add {minified: true} to the library definition.

To get minification of contrib or custom code, they'd need to do the same file structure/library definition update.

So this would mean retaining, but repurposing the build step, and the js dev dependencies (+ new ones), but not requiring node.js on production since everything's shipped pre-minified.

It is obviously way more efficient for sites if they literally never have to minify anything, so it's a question of implementing it and maintaining the build step over time vs. the Peast approach.

wim leers’s picture

To get minification of contrib or custom code, they'd need to do the same file structure/library definition update.

This is the reason we need it: we cannot expect contrib/custom to do this.

IOW: if a site wants guarantees about minified JS (without having to have patches for most contrib modules), we need to do this issue.

Right? I'd love to be told I'm wrong about this, because I 100% agree with @catch's statement:

It is obviously way more efficient for sites if they literally never have to minify anything, so it's a question of implementing it and maintaining the build step over time vs. the Peast approach.

longwave’s picture

If we want to improve performance we could take both approaches? Allow shipping minified JS, and don't try to re-minify that - we already ship many vendored *.min.js files. Provide tooling to minify JS at build time, use that for core libraries, and allow contrib/custom to do that if they wish. But if contrib/custom can't use that tooling, then we let Peast do the work.

wim leers’s picture

#35 triggers a strong Why did I not think of that? feeling 😄👍

catch’s picture

Issue summary: View changes
Issue tags: -Needs issue summary update

Yes #35 is great, let's do that!

I've made a start on the dependency evaluation for Peast and tried to reflect the past couple of comments in the issue summary.

nod_’s picture

Status: Needs work » Needs review
StatusFileSize
new8.78 KB
new1.07 KB
nod_’s picture

Issue summary: View changes

Posted some questions in the peast queue about security: https://github.com/mck89/peast/issues/51

wim leers’s picture

What does the typical real-world performance impact look like with our new approach? 🤓

nod_’s picture

we have some data in #9 and #14 but I don't have access to a "real" site to test this on.

Also anyone using tracking/googletag manager/ads will not see any change from this patch, since they already spend several seconds on mobile just executing tracking/ads scripts.

wim leers’s picture

since they already spend several seconds on mobile just executing tracking/ads scripts.

🔥🤣👏

I was looking for a re-do of the data in #9 where @catch wrote Finally, browser timings from chromium for loading aggregates (and modernizr) - this is on localhost, so the least-friendly test given a 'normal' request would involve network latency.

i.e. is that better in the current approach than in the approach that was tested in #9? That's what @longwave's #35 proposal that is now implemented should do: it should have better performance, but A) does it actually have that, B) how much better?

catch’s picture

@Wim Leers #38 is functionally the same patch as was tested in #9 so the numbers are still valid if we only did this issue.

To be able to redo those timings with the 'new' #35 approach, we'd first need to repurpose the JavaScript build step to minify JavaScript files, and also add minified: true to the respective assets in library definitions. We don't have an issue for that yet, should probably be its own issue separate from this one since the change will be completely different.

Once that's done, we could compare the timings to #9, but it'd also be exactly the same as in HEAD because no minification would be happening and the code added here would be nearly a no-op, there'd just be smaller pre-minified assets to serve.

To test the hybrid approach (pre-minification for core, Peast minification for everything else), we'd need a custom-js-heavy contrib module too. And the asset generation would be whatever it is now + however many miniseconds for Peast to minify the files from that contrib module.

wim leers’s picture

Right — I didn't think through the mechanics of doing what I asked/described in #42 🙈

But … isn't that that data I described in #42 what a core committer like yourself would like to see before committing this? Or is there a reason here that you'd be comfortable committing this without such data?

EDIT: also, it was me who added the minified flag in #2307717: Inform Drupal in *.libraries.yml via a new per-JS file "minified" flag whether a JS file has been minified many years ago, and made JsCollectionOptimizer respect it, so what @longwave proposed has actually already been the case for a long time 😅

wim leers’s picture

Title: [PP-1] JavaScript minification » JavaScript minification

And while I was writing that, #2258313: Add license information to aggregated assets landed, which means this is unblocked! 🚀

catch’s picture

@Wim well the question is really whether this issue is postponed on the pre-minification of core files or not. I've just opened #3308122: Pre-minify core JavaScript so that we can start working on that.

Without that issue, the numbers for core will be the same as #9.

With that issue, the numbers for core will be significantly better than #9, varying on just exactly how many js-heavy contrib modules with unminified JavaScript are in use.

A big motivator behind #1014086: Stampedes and cold cache performance issues with css/js aggregation (apart from the improvements to stability) was moving aggregate generation out of the critical path of generating HTML requests, so that we could then do expensive things like minification. So if we hadn't come up with @longwave's hybrid approach in #35 I probably would still have argued to commit the Peast version of this despite the performance 'regression', because when combined with other changes (and real life front end performance as soon as the aggregates are generated) it would still be a performance improvement overall.

catch’s picture

Title: JavaScript minification » On-the-fly JavaScript minification

Re-titling so it's easier to tell the difference between this and #3308122: Pre-minify core JavaScript.

nod_’s picture

We got a reply from peast maintainer about the security questions. https://github.com/mck89/peast/issues/51#issuecomment-1239084168 Essentially this is a hobby project from a single maintainer, and that he'd be happy to share maintainership since there is new syntax to implement each year when ecma releases a new version and he can't guarantee how long he'll be able to maintain the library.

catch’s picture

StatusFileSize
new2.15 KB
new8.54 KB

Obviously would be great if he's said 'definitely going to be maintained at least the next decade' but also that's a very open and honest answer and he seems happy to accept help.

New patch - needed a re-roll for composer.lock changes, and adding caching of the Peast step.

Doesn't include pre-warming yet.

nod_’s picture

cache warming could be a follow up no?

catch’s picture

StatusFileSize
new1.53 KB
new9.13 KB

Yeah I personally think a follow-up would be fine for pre-warming. The caching in the current patch means that aggregates for anon vs. auth vs. front page vs. forms vs. admin pages won't be minifying the same files more than once already.

Fixing the typos (should've been in the last patch but I uploaded the wrong one :/).

nod_’s picture

Issue summary: View changes
Status: Needs review » Reviewed & tested by the community
Issue tags: -Needs change record

catch created the change record already

Added follow-up for cache warming here #3308398: [PP-1] JS minification cache warming

Assuming this is green RTBC from me only code I touched was to make the commit check pass.

wim leers’s picture

Status: Reviewed & tested by the community » Needs work
  1. +++ b/core/lib/Drupal/Core/Asset/JsOptimizer.php
    @@ -3,12 +3,21 @@
    +  /**
    +   * Constructs a JsOptimizer.
    +   */
    +  public function __construct(protected CacheBackendInterface $cache) {}
    

    🐛 Incomplete docblock. And does not yet create $this->cache. And protected $cache is missing from the class.

  2. +++ b/core/lib/Drupal/Core/Asset/JsOptimizer.php
    @@ -30,9 +44,13 @@ public function optimize(array $js_asset) {
    +    // Remove comments, whitespace, and optional braces.
    +    $ast = Peast::latest($data)->parse();
    +    $renderer = new Renderer();
    +    $renderer->setFormatter(new CompactFormatter());
    +    $rendered = $renderer->render($ast);
    

    🤔 Given this logic, shouldn't we remove \Drupal\Core\Asset\JsOptimizer::clean()'s current logic too?

    It currently does this:

        // Remove JS source and source mapping urls or these may cause 404 errors.
        $contents = preg_replace('/\/\/(#|@)\s(sourceURL|sourceMappingURL)=\s*(\S*?)\s*$/m', '', $contents);
    
  3. +++ b/core/scripts/dev/commit-code-check.sh
    @@ -384,7 +384,7 @@
    -  if [[ -f "$TOP_LEVEL/$FILE" ]] && [[ $FILE =~ \.js$ ]] && [[ ! $FILE =~ ^core/tests/Drupal/Nightwatch ]] && [[ ! $FILE =~ /tests/src/Nightwatch/ ]] && [[ ! $FILE =~ ^core/modules/ckeditor5/js/ckeditor5_plugins ]]; then
    +  if [[ -f "$TOP_LEVEL/$FILE" ]] && [[ $FILE =~ \.js$ ]] && [[ ! $FILE =~ ^core/tests/Drupal/Tests/Core/Asset ]] && [[ ! $FILE =~ ^core/tests/Drupal/Nightwatch ]] && [[ ! $FILE =~ /tests/src/Nightwatch/ ]] && [[ ! $FILE =~ ^core/modules/ckeditor5/js/ckeditor5_plugins ]]; then
    

    Why this change? 🤔

nod_’s picture

#53.3: because https://www.drupal.org/pift-ci-job/2459955, those files were created without the .es6.js corresponding files because they were created before that, and before the commit checks were in place, and were not touched since. This is the first time they go through the commit checks and should be excluded since there is no reason for them to have an .es6.js version.

Thought about the clean, but if someone overrides the optimize process, there is no guarentee they'll remove those piece of comment? I guess we could remove them but it doesn't hurt to keep?

longwave’s picture

Re #53.1 note the protected keyword in the parameter list; this is PHP 8 constructor property promotion in action which automatically creates a class property and sets it.

Having said that, the parameter needs BC handling I guess.

longwave’s picture

+++ b/composer.json
@@ -12,7 +12,8 @@
+        "mck89/peast": "^1.14"

This needs to go in core/composer.json as it's a runtime dependency.

catch’s picture

#53.1: the param docs are indeed missing, but the rest is #3278431: [May 2026] Use PHP 8 constructor property promotion for existing code! Another example in #1014086: Stampedes and cold cache performance issues with css/js aggregation. Trying to do it for new code, existing code is waiting until post-beta or later. Also @longwave's right we should do basic bc handling for the parameter.

#53.2: hmm given that's in comments and they're being stripped, we probably can! But nod_'s right this is a bit of a behaviour change so maybe in a follow-up so it gets its own CR etc?

#53.3: see #3302755-26: On-the-fly JavaScript minification.

wim leers’s picture

#55 & #57.1 🤩 OMG YES RM -F BOILERPLATE

#57.2: WFM!

Only keeping at Needs work for #56 now :) Can't wait to see this land!

catch’s picture

Status: Needs work » Needs review
StatusFileSize
new2.87 KB
new9.72 KB

This hopefully does it for the constructor bc. Still adjusting to the combination of property promotion + bc.

longwave’s picture

+++ b/core/lib/Drupal/Core/Asset/JsOptimizer.php
@@ -3,12 +3,30 @@
+  public function __construct(protected ?CacheBackendInterface $cache) {

FWIW after using this in real world projects for a while I am kind of in favour of using explicit multiline syntax here, e.g.

  public function __construct(
    protected ?CacheBackendInterface $cache,
  ) {

as it makes it clearer to me that property promotion is being used - especially if we are to drop the @param section of the docblock. I guess this is worth discussing in the policy issue as a standard.

wim leers’s picture

#60: that'd make it easier to modify the constructor in the future for sure: less disruptive. But yeah, it's really a detail.

catch’s picture

StatusFileSize
new938 bytes
new9.74 KB
protected ?CacheBackendInterface $cache,

I was going to say I'm not a huge fan of multi-line parameter declarations because I always want to add the trailing comma and PHP doesn't allow it. But PHP 8.0 does allow it!

catch’s picture

StatusFileSize
new9.74 KB

Interdiff is right, patch was wrong :(

wim leers’s picture

Issue summary: View changes
Status: Needs review » Reviewed & tested by the community
Related issues: +#3308398: [PP-1] JS minification cache warming

Beautiful. 🤩 Can't believe that after a ~decade of trying to make this happen, it turns out to be such a compact patch! 🤩

  • 👍 Dependency evaluation is present.
  • 👍 Release note snippet is present.
  • 👍 Change record is present.
  • 🤓 Tweaked issue summary and change record for clarity.

⇒ RTBC!

See y'all in #3308398: [PP-1] JS minification cache warming.

wim leers’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs tests

Okay, I was a bit too enthusiastic there 🙈🙈🙈

While struck by the elegance and compactness of the patch … I conveniently overlooked the fact that there is no test coverage yet. I think we should have simple test coverage to confirm that:

+++ b/core/lib/Drupal/Core/Asset/JsOptimizer.php
@@ -30,9 +55,13 @@ public function optimize(array $js_asset) {
+    // Remove comments, whitespace, and optional braces.
+    $ast = Peast::latest($data)->parse();
+    $renderer = new Renderer();
+    $renderer->setFormatter(new CompactFormatter());
+    $rendered = $renderer->render($ast);

indeed results in comments, whitespace and optional braces getting stripped.

catch’s picture

Status: Needs work » Needs review
Issue tags: -Performance, -Needs tests +Page loading performance
StatusFileSize
new1.58 KB
new11.02 KB

There's some implicit whitespace removal with some of the test file changes, but yeah let's add a more explicit one.

nod_’s picture

Status: Needs review » Reviewed & tested by the community

Looks good to me 😊

wim leers’s picture

Perfect! 🤩 Even simpler test coverage than I dared hope for!

alexpott’s picture

+++ b/core/lib/Drupal/Core/Asset/JsOptimizer.php
@@ -3,12 +3,21 @@
+  /**
+   * Constructs a JsOptimizer.
+   */
+  public function __construct(protected CacheBackendInterface $cache) {}

@@ -20,6 +29,11 @@ public function optimize(array $js_asset) {
+    $cid = 'js_optimizer:' . $js_asset['data'];
+    if ($cached = $this->cache->get($cid)) {
+      return $cached->data;
+    }

I wonder whether this cache is worth it? We're going to dump the results to disk and use that. I also wonder if this cache is going to cause pain if you change a JS file and change the js/css query string thing.

nod_’s picture

In the situation where you have 10 different aggregates that all include the same js file (for some reason) that file would be minified the same way 10 different times. Since we can't predict the aggregates and how many are going to be built based on user permissions and such, having a cache makes sure we don't do work that could be prevented. And if your file is big we could easily save like a second overall by using the cache.

Does the query string work without clearing the cache today? the file name depends on the file contents so just changing the query string wouldn't update the content of the file. When I need to update aggregates I clear the cache, that'll work the same here. There is no more query string cache busting in the new urls so that's a hint it works differently as before no?

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

What I think is very different is if you clear the aggregates and do a deployment then JS files might not change. Thinking about this some more I think we should be using a filecache object here. That'll key using the mtime of the file - see \Drupal\Component\FileCache\FileCache::set() - I think doing that will prevent some really odd surprises.

nod_’s picture

Ok I see the problem now, got bitten by it trying to debug some js and the agreggate not being generated correctly

nod_’s picture

Tried to work out how to use FileCache, seems like we need to implement a DatabaseBackendCacheBackend for the FileCache so that the minified result is stored in the DB and not in acpu or in the static cache. Does that sound about right?

catch’s picture

StatusFileSize
new9.97 KB

Looking at FileCache, first a re-roll.

catch’s picture

Status: Needs work » Needs review
StatusFileSize
new9.37 KB

Should look something like this. There are not that many files to minify and the minified versions should be in the hundreds of kilobytes, so I think we can just use the apcu backend, if that's a concern I think adding a database backend should be in a follow-up.

wim leers’s picture

so I think we can just use the apcu backend

Isn't that already very quickly full on more complex sites?

And … "hundreds of kilobytes" really just depends on the amount of JS on a particular site, doesn't it? 😅

catch’s picture

StatusFileSize
new1.31 KB
new9.02 KB

Isn't that already very quickly full on more complex sites?

Well maybe but asset aggregation tends to be a lot of requests for a few minutes, then very few after that, so if it's happening after a cache clear and items then get expired that seems OK? I don't particularly mind adding a database FileCache backend but it seems out of scope here, would prefer to get this in without any caching at all than blocking it on implementations for unrelated components.

And … "hundreds of kilobytes" really just depends on the amount of JS on a particular site, doesn't it?

Yes, and it'll also be affected by #3308122: Pre-minify core JavaScript which will mean the logic here only happens as a fallback for contrib/custom JS that's not pre-minified.

Status: Needs review » Needs work

The last submitted patch, 77: 3302755-77.patch, failed testing. View results

catch’s picture

Status: Needs work » Needs review

Test failures are all unrelated, chromedriver is having bad day.

The last submitted patch, 74: 3302755-74.patch, failed testing. View results

alexpott’s picture

+++ b/core/lib/Drupal/Core/Asset/JsOptimizer.php
@@ -20,6 +39,11 @@ public function optimize(array $js_asset) {
+    $cid = $js_asset['data'];
+    if ($cached = $this->fileCache->get($js_asset['data'])) {

->get($cid) would be more consistent for me. So it matches the set lower down.

Re #77 - how much does the cache actually save us?

catch’s picture

StatusFileSize
new482 bytes
new9 KB

Fixing $cid.

I did some basic timings in #7 which should still be relevant for the current patch.

Small files like touchevents-test.js take ~1-2ms to process each.

drupal.js and contextual.js take ~18ms each.

ajax.js is the worst at ~97ms.

Any file that's 10-100ms the caching will be a net-positive if we get one or more cache hits.

However, the question then is what is the cache hit rate going to be like.

I took a look at the js directory of one production Drupal 9.4 site - this is a fairly complex site although it is not high-traffic.

js aggregates are kept around for 6 weeks with the current logic, so that gives us data since September 7th.

ajax.js was in 276 different aggregates produced since September 7th. This amounts to 27 seconds of minification time if we were minifying it each time.

This site has had a handful of deployments since September 7th, and the files are mostly created during deployments, then not until the next one. So you tend to get 5-20 aggregates per-deployment (anonymous, regular user, admin account, couple of different pages with different JavaScript on them etc.) then a gap in dates until the next tone.

If someone wants to do a comparison on a different site, I just did some grepping for toConsumableArray, this will tell you how many aggregates with that string were created in the past seven days:

find ./ -type f -mtime -7  -print0 | xargs -0 grep -l toConsumableArray | wc -l

On this site it was 44 in the past seven days.

Based on this I think it is reasonable to say that a file like ajax.js will probably get minified 5-10 times almost immediately after a deployment, and then barely touched until the next deployment. This means we'd save 500ms to 1s from caching minification of that file in total across aggregate requests, and if it then gets chucked out of the apcu cache later no-one will notice.

Given all that I don't think it's critical to add caching here at all, it's definitely a nice to have (and especially once we pre-minify core JavaScript since those won't need to be minified or cached at all), but it will slightly help the immediate cold-cache situation you get after deployments in those first few seconds/minutes.

Status: Needs review » Needs work

The last submitted patch, 82: 3302755-82.patch, failed testing. View results

catch’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work

The last submitted patch, 82: 3302755-82.patch, failed testing. View results

catch’s picture

Status: Needs work » Needs review
StatusFileSize
new4.9 KB
new7.99 KB

Discussed the caching a bit more with @alexpott. Although it's likely it will reduce the number of times the same file gets minified, we also have other ways to do that:

- pre-minification of core JavaScript (so on-the-fly only runs for contrib and custom JavaScript)
- reducing the variations in aggregates (i.e. by making ordering more consistent)

Also the hit rate is always going to be low - a dozen or so hits at a time.

So... new version with caching removed, if it turns out we really do need it, we could add it in a follow-up.

nod_’s picture

Status: Needs review » Reviewed & tested by the community

Still working as expected :)

+1 to remove caching, we have 6 months to fix if it ends up being an issue

wim leers’s picture

Magnificently simple patch for an incredibly high-impact front-end performance improvement!

RTBC++


Although … my inner nerd would definitely like to see a test that proves that this saves at least X bytes with aggregation on vs aggregation off on Standard's front page and /node/add/article route, which is possible in a FunctionalJavascript test that inspects window.performance — see \Drupal\Tests\ckeditor5\FunctionalJavascript\MediaTest::getLastPreviewRequestTransferSize() and \Drupal\Tests\ckeditor5\FunctionalJavascript\AdminUiTest::getAjaxResponseCount() for examples. But that's just a nice-to-have.

Heck, it'd just require additions to \Drupal\Tests\standard\FunctionalJavascript\StandardJavascriptTest!

Reason I'd like this: that way we'd have some "executable documentation" that proves the real-world impact of this on a fresh Drupal site 🤓

catch’s picture

I think it could make sense to add that test coverage, but only if we weren't also planning to do #3308122: Pre-minify core JavaScript which would make the test coverage redundant again (i.e. that issue renders this code a no-op with only core JavaScript so there'd be no size reduction because nothing would be minified with Peast in core), given that I'm -1 on adding something we'll (hopefully) have to remove again in a few weeks/months.

wim leers’s picture

D'oh, right — that makes sense! Although I guess we could simulate it by installing a test module whose JS we would not minify in #3308122 — thus proving it'd correctly help improve things for contrib. Then all that would change in the test coverage in #3308122 is that the expected minimum bytes saved would change!

longwave’s picture

nod_’s picture

All that could be follow ups, let's not drag patches more than they need to. Much easier for people to get involved in an "add test" issue than a "100 comments and implementation discussion + add test issue"

longwave’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/lib/Drupal/Core/Asset/JsOptimizer.php
@@ -30,14 +33,14 @@ public function optimize(array $js_asset) {
   /**
-   * Processes the contents of a javascript asset for cleanup.
-   *

Removing this comment is out of scope?

catch’s picture

Status: Needs work » Reviewed & tested by the community
StatusFileSize
new7.84 KB

Just putting back the stray comment how we found it.

@Wim I'm not sure how a test module would be particularly more realistic than adding a bigger source file to the minification unit test and comparing the bytes there. But yes let's discuss in a follow-up.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

We should add 'mck89/peast' => ['doc'], to \Drupal\Composer\Plugin\VendorHardening\Config::$defaultConfig

  • longwave committed 79035ac on 10.1.x
    Issue #3302755 by catch, nod_, xjm, Wim Leers, longwave, alexpott, mfb:...
longwave’s picture

Status: Needs work » Fixed

Committed and pushed 79035ac12d to 10.1.x. Thanks!

Also published the change record.

edit: crosspost with @alexpott; let's do that in a followup, sorry.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

catch’s picture

Issue summary: View changes
Issue tags: +10.1.0 release notes
xjm’s picture

This was already added to the 10.1.0 release notes draft.

andypost’s picture

edysmp’s picture

example JS code:
```
if (true) {
const CMS = 'Drupal';
}
```
Peast parses to: `if (true) const CMS = 'Drupal';` which is: `SyntaxError: Unexpected token 'const'`

contrib code affected https://git.drupalcode.org/project/glightbox/-/blob/1.0.x/js/glightbox.j...

longwave’s picture

@edysmp can you raise that at https://github.com/mck89/peast/issues? The Peast maintainer does not monitor this issue as far as I am aware.

const is block scoped in JavaScript, which means the variable is pointless (it goes out of scope on the next line) and there must be another side effect going on, but this probably still should be considered a bug.

edysmp’s picture

@longwave Thanks for taking a look. Post the issue in Peast project: https://github.com/mck89/peast/issues/60

edysmp’s picture

FYI contrib module affected mentioned in #102 is no longer an issue. Fixed by https://www.drupal.org/project/glightbox/issues/3378356#comment-15174019

longwave’s picture

@edysmp the Peast maintainer has fixed this bug and released a new version: https://github.com/mck89/peast/releases/tag/v1.15.3

Thanks for raising it!

edysmp’s picture

@longwave Great! So, next is to update Peast here? Worth a follow-up at this moment?

anybody’s picture

Sorry to comment on this fixed issue, but I hope we can get a short information here, if this needs a follow-up issue.

In COOKiES (for GDPR) we need to disable preprocessing (preprocess: false) because the JS files may not be aggregated to be able to block them.
But setting preprocess: false has the side-effect that the resulting JS wll also not be minified, while it would be totally okay aka "good" to have it minified.

So I think it may need a more granular separation of "preprocess" / "aggregate" / "minified" to be able to solve such cases?