Problem/Motivation

There are some CSS and JS libraries that consist of multiple files (so worth aggregating) and either loaded on every page (or only loaded on very specific pages like one admin page, less interesting but worth thinking about).

We can add css_aggregate_target and js_aggregate_target library keys, and when we build CSS groups etc. put that library into a single aggregate, with a differently constructed URL that only contains the library, no delta, not the other libraries on the page. Can also allow that to be set to a string, so that it libraries can be grouped together.

This will maximise browser and CDN cache hit rates, because regardless of whatever other libraries on the page, not only the aggregate file path itself, but also the query arguments will be identical. The trade-off is slightly more files on some pages, but generally it's 1-3 additional files per page.

Once we've done this issue, we'll be able to add #3563782: [PP-1] Use early hints for CSS on top - because we can then construct the specific URL for an aggregate that we know will be loaded on a page - before the entire page is built and all #attached are processed.

Steps to reproduce

Proposed resolution

Remaining tasks

User interface changes

Introduced terminology

API changes

Library definitions can now specify css_aggregate_target: true and js_aggregate_target: true, these will result in all the files in a library definition being served in a unique aggregate specific to that library, regardless of other libraries on the page.

Examples of where this is useful is if you have a library with lots of individual files or a couple of large files - when this is served in its own aggregate, it will always be the same file regardless of which page you're on, improving the browser, CDN etc. cache hit rate.

If a library sets individual weights for specific files, these will be respected, but if that results in the library being 'split' across multiple aggregates, the aggregate target will be ignored, e.g. it falls back to the current behaviour.

If there are multiple libraries that will always or almost always be loaded together, it's possible to specify an arbitrary string for css_aggregate_target. This can help to reduce the number of separate files to download while also increasing cache hit rates, however you should be confident that the libraries actually can be served in a single aggregate when using this option.

Data model changes

Release notes snippet

Issue fork drupal-3565258

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

catch created an issue. See original summary.

catch’s picture

Status: Active » Needs work

Asset system unit and kernel tests are green, Umami front page looks OK, see what happens with everything else.

catch’s picture

The aggregate counts are increased a bit more than expected here because views.module.css ends up in its own file as a side-effect, at least in Umami. Opened #3565297: Move 'grid' and 'display link' CSS to their own views libraries because we should be able to remove that entire library from most pages.

catch’s picture

Status: Needs work » Needs review

MR should be green.

This uses fundamentally the same approach as #3232810: [PP-1] Allow setting aggregation groups for js files in library definitions. There is one major extra thing to worry about with CSS which was a pain to get right.

Because we add CSS in categories within a single library definition, and those categories are translated in to different weights, then both Olivero and Umami's global library, once you add unique_css_aggregate, actually can't be one aggregate but has to be up to three. This allows other libraries with the same categories to slot in-between. This means we need to send the libraries + category as query parameters.

For Umami it also sets form.css to -10 weight which splits things further and prevented the grouping logic from kicking in - which shows that it's resilient to setting it on libraries that won't work, it falls back to the existing behaviour, but not great for testing. To deal with this, I split the umami library into two and added a dependency, the new library doesn't get unique_css_aggregate: TRUE so is handled as normal, allowing the main one to be 'clean'.

For Olivero, it has enough files in each category, and doesn't set custom weights per file, so it 'just works' for Olivero without any workarounds. We don't have performance test coverage for Olivero, but manually verified it has the expected effect.

AssetAggregationAcrossPages test is showing what we want it to - the total stylesheet bytes goes down by quite a lot because we're producing less aggregates with duplicate CSS in them.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new6.73 KB

The Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

catch’s picture

Status: Needs work » Needs review
catch’s picture

I did a quick local test to see what it would look like if we used this approach for all CSS libraries - e.g. have every library in its own aggregate. It's far too many - somewhere between 15-25 depending on Olivero vs Umami. We'd have to restructure libraries into larger ones when we know they're going to appear on every page. Could maybe look at that in a follow-up, but would have to do a lot of prerequisite work first.

However I also noticed we split aggregates via the CSS 'group' key, which maps to asset categories. This dates all the way back to 2010 from #769226: Optimize JS/CSS aggregation for front-end performance and DX, before we had the libraries system - when files were added individually via drupal_add_css().

Since we added the libraries system, a single library can contain CSS from multiple CSS categories (theme, component, base etc.) so there is absolutely no benefit (or any benefit would be purely by chance) to splitting aggregates by that and likely hasn't been since the shift to libraries.

Dropping that reduces the aggregate count by 1 on each page in Umami tests, and the 'across pages' test still finds the same amount of CSS, so shows it's not resulting in any more duplicates.

catch’s picture

Priority: Normal » Major

With all the above this is a significant improvement with no drawbacks (at least after #3565297: Move 'grid' and 'display link' CSS to their own views libraries and #3436855: Remove the views-align-* CSS classes which will reduce the aggregate count by another 1 as well as overall CSS), and it unblocks #3563782: [PP-1] Use early hints for CSS so bumping to major.

catch’s picture

Title: Support library-specific CSS aggregates » Support library-specific aggregates

Merging in JavaScript support from #3232810: [PP-1] Allow setting aggregation groups for js files in library definitions since this will be easier to review in one go - a lot of the test and code changes are the same, or where they're different they're very similar.

catch’s picture

Issue summary: View changes
catch’s picture

Issue tags: +gander

OK this should be ready for review.

I updated Umami and several core libraries to use the new API. I also added preprocess: false to a couple of single-file libraries for vendor dependencies in core (loadjs etc.).

The guidelines would be:

1. Multiple-file libraries, especially with unminified files - good candidate for unique_js/css_aggregate

2. Files with a single, large, unminified file - good candidate for unique_js/css_aggregate

3. Files with a vendor, minified file - good candidate for preprocess: false because the contents of the eventual aggregate would be identical to the raw file on disk anyway.

Libraries that aren't really helped:

1. Libraries with a single small, unminified file
2. CSS libraries with one each of small files in different CSS groups, because those are given vastly different weights so don't appear together in the eventual file order.
3. Any library that uses weights for individual files (unless they're all given the same weight).

However if a library incorrectly adds unique_css_aggregate, either due to it's own definition or a different library causing it to be split, the system falls back to the current behaviour transparently (this is the $seen_index logic).

I updated Umami's library definitions to use the API, had to split a couple of files out of the global library because of weights.

Performance tests show that we end up with 2-5 additional aggregates on a page, but successfully reduce the total bytes transferred when multiple pages are visited, in all the multiple page tests in Umami.

Libraries that would benefit from this but require restucturing to do so:

- all the libraries using jquery_ui, this approach can't help them because they micro-tune weights in between the main jquery_ui library. Better to move towards non-jquery_ui alternatives than worry about this too much.

- ckeditor5 libraries because they're all one file-per-library. This is all going to have to change due to #3527914: Use UMD installation method for CKEditor5 so better to wait until after that issue is done.

Once we add in #3563782: [PP-1] Use early hints for CSS, we'll be able to early hint CSS-containing libraries that use unique_css_aggregate, to get render blocking CSS sent before the full page is sent - this is made possible because of the more predictable URL structure here (e.g. these libraries get exactly the same URL regardless of which other libraries are on the page, no delta, no include/exclude parameters will the full list of libraries).

berdir’s picture

I'll have to wrap my head around the implications and changes to this.

Umami has quite a few distinct libraries. The frontpage now for example loads recipe-collections, which contains just 4 rules. that ends up in a separate aggregate now because it's the only extra library. Is it really worth splitting up tiny definitions like this? Do you know of any guides docs around tradeoff between loading unused CSS and extra files/requests?

system/base is also pretty small, and it's loaded by system modules. If I'd want to optimize this, is there any benefit in putting those every-request libraries files into a shared aggregate? Does it even matter with HTTP/2 and stuff?

Will try it out on my project.

catch’s picture

Does it even matter with HTTP/2 and stuff?

Aggregation is still (very) important but the specific number of files matters less.

https://blog.khanacademy.org/forgo-js-packaging-not-so-fast/ has some info from people who tried to remove aggregation altogether and found out that was a massive mistake. This was a popular opinion in c. 2016 when http/2 didn't actually exist in reality, along with some other overly optimistic ideas about what it would do like http push.

Also https://www.manning.com/books/http2-in-action the 'optimizing for http/2' section has more info too.

The short version is that http/2 allows multiple requests (streams) over a single connection. Servers like apache2/nginx, and CDNs, and also browsers, can then determine how many requests they allow at once. With servers it appears to vary between about 100-120 - this will be shared between CSS, JS, images and fonts from the same domain. There is no hard number to work against, cloudfare explicitly varies this even between plans https://developers.cloudflare.com/speed/optimization/protocol/http2-to-o...

For comparison, IE8 used to allow a maximum simultaneous requests of 6 per server.

What I think this means for us, is that we can afford a handful of extra requests per page if that results in better hit rates (whether that is for the individual browser, for the CDN, for the Drupal aggregate controllers), but there is only a certain amount of headroom. Obviously better cache hits rates results in less http requests too overall, or will even them out (but it doesn't in our performance tests yet partly due to the specific issues you raise above).

Do you know of any guides docs around tradeoff between loading unused CSS and extra files/requests?

There's a bit here:

https://developer.chrome.com/docs/devtools/coverage
https://web.dev/articles/extract-critical-css

We are already significantly ahead of most projects with the library + attached system in minimising unused CSS (compared to literally loading all CSS for the site on every page), the main problem we have is legacy CSS added before we started using that system properly like system/base. I think it would be reasonable for a theme like Umami to include it's front page and very commonly seen components in its 'critical CSS' if the amount of CSS is very small after this issue, since we know they won't also get duplicated between aggregates then.

The biggest trade-off for unused is with inline CSS, and to a lesser extent with early hints.

With #3499829: Support inlining critical CSS for faster core web vitals we would want absolutely the minimum amount of CSS loaded on a page because inlining CSS breaks browser caching altogether and bloats the HTML document, I'm partly working on this issue because I think early hints is a better approach for us.

With early hints, we can potentially start transferring all of the render blocking CSS for a page before the page itself is sent. This would especially be the case with #3508508: Allow big pipe to run for session-less users because big pipe makes CSS attached within placeholder rendering non-blocking (loaded via loadjs). That way requests that are slower, because they are page/dynamic/render cache misses will reach Largest Contentful Paint faster because the CSS for the page skeleton is sent as early as possible, and any later CSS doesn't block rendering. If we want to optimize for 'the highest percentage of pages should have good LCP' then I think there is a lot of potential here.

For page cache and dynamic page cache hits that already return a response in < 50ms, with http/2 a handful of render blocking files should take about the same amount of time as one render blocking file (@lleber found out in the inline CSS that even an empty render blocking CSS file has about the same latency problem as not inlining any CSS), so while it won't be an improvement for those, it also shouldn't make things worse. It will make things better if we successfully improve the cache hit rate for multi-page visits.

If I'd want to optimize this, is there any benefit in putting those every-request libraries files into a shared aggregate?

There is the potential to add an arbitrary string to unique_*_aggregate so that libraries can be grouped together, the problem is this increases the likelihood of split aggregates again. e.g. if someone inserts a library between your library and system/base, but only on some pages, then you can end up with the combined aggregate with two libraries as well as two separate aggregates, and also potentially falling back to the include/exclude system if a library gets split. I did try versions of this a few times, but it makes things really complicated and more fragile. We could try it in a follow-up, it might need the library weights/ordering issue to be done though.

For system/base I have been trying to kill it, it's used to be 7kb, now it's one 1kb with more to go in #2880237: [meta] Refactor system/base library. However actually getting it to zero may be impossible. What we might be able to do is add a special theme .info.yml key to 'absorb' the system/base library into the global CSS library for the theme - we could look for that in a library info alter, and move all the files over from system/base, leaving it empty.

catch’s picture

More reading:

https://www.smashingmagazine.com/2025/01/tight-mode-why-browsers-produce...

Both Chrome and Safari constrain loading of non-critical resources until the body is attached in some circumstances. This won't affect CSS loading, but it might affect loading of images if there are lots of CSS files to download (because then the images would be throttled until the CSS is done). Firefox doesn't have a 'tight mode'. This behaviour is completely undocumented and subject to change at any time.

What does that mean for this issue? We should definitely keep going with issues like #3565297: Move 'grid' and 'display link' CSS to their own views libraries so that on-demand CSS is really only loaded on-demand. With the status quo of that views library, we have something that isn't loaded on every page, and it's also not loaded only when needed; it's loaded on only some pages but much more often than if it was loaded with the components.

catch’s picture

The frontpage now for example loads recipe-collections, which contains just 4 rules. that ends up in a separate aggregate now because it's the only extra library.

Specifically on this, I checked the configuration of the recipe collections block in Umami and it's loaded on every page. In that case, it would make sense to absorb the library definition into umami/global because it actually is loaded globally. It's doing a similar role to the main navigation and footer blocks in Umami which are also on every page.

catch’s picture

I pushed an additional branch/draft MR which supports an arbitrary string for 'unique_css_aggregate' (not js yet). With this I was able to get the umami recipe collections library to combine with the rest of the umami global library, but only by changing the CSS group to 'component'. the other libraries with unique_css_aggregate: 'every page' still ended up in their own dedicated aggregate because they're surrounded by other libraries that don't have it, which is the limitation of that approach described in #14.

At least with umami but also more generally, a lot of the CSS in core is in fairly arbitrary groups - why is system/base in 'component' and not 'base' for example?

I also noticed that Umami's global CSS library already has a lot of page-specific CSS like recipe instructions, so it's already not optimized to avoid unused CSS. If it was, the global library would be smaller and there would be slightly larger page-specific aggregates.

catch’s picture

Realised there are good reasons to add #18 here, partly because it turned out to be a very small addition.

Sticking with the recipe collections block for an example, in Umami this is on every page. Let's assume Umami was a site template and someone installed it, didn't like the block and removed it entirely. If we use unique_css_aggregate: 'arbitrary_string', then combined with the library either being on all pages or none, then it either ends up in the same aggregate as other things, or it's not on the page at all. If we actually moved the file into the main Umami CSS library, then it would be added even if the block is completely removed.

The less-ideal case of the 'unique' aggregate being split into multiple unique aggregates is no worse than if we don't provide this option, the worst case of an individual library being split are no worse than HEAD status quo. But what it will help to discourage is moving components into big monolithic libraries in more generic themes like Olivero - we can put a few 'every page' libraries together in Olivero, but leave them independent - an example would be the search component where search may or may not be installed.

It would be possible for someone to go in and configure the block to be only shown on the front page or only on node pages, then that would mess up the 'every page' hint, but again the worst case is either a finite, but higher, number of 'unique' aggregates, or the fallback to current behaviour. Even if you have libraries sometimes combined on some pages, sometimes split on others, the number of variations is still restricted to the number of libraries involved in that specific set of aggregates, not all the libraries on the page. So the number of combinations should be less.

Closed the draft MR and moving things over to the main one.

https://git.drupalcode.org/project/drupal/-/merge_requests/14203/diffs?c... shows the test change - less files and also less bytes, because the smaller aggregate is absorbed into the bigger one, and also the CSS isn't duplicated between aggregates any more.

catch’s picture

Found another issue in Umami - one of the layout libraries is put in the 'theme' group, despite it shipping another layout in the 'layout' group, moving the layout library to the layout group and that knocks another http request off.

Need to add arbitrary string support to js library groups stilll, could probably use that for htmx.

catch’s picture

Need to add arbitrary string support to js library groups stilll, could probably use that for htmx.

This doesn't work because of loadJs, unless we add loadJs to the htmx group too, but it's also an AJAX dependency still.

catch’s picture

Did some experimental work locally and we can at least partly solve #3224261: [PP-1] Front-end performance: concatenate CKEditor 5 JS assets for every unique configuration using this, but we should do it in that issue because it's a lot of libraries to update.

berdir’s picture

Started testing this on our project.

Adding "unique_css_aggregate: 'every_page'" to our global CSS library cut the total stylesheetbytes size to 40% of the previous sum on HEAD when visiting 5 different pages in a row (where I assume all have some unique CSS being loaded).

But, the CSS file count goes from 3 to 8 just by that single change. What's weird is that I end up with 2 completely empty CSS file (the only thing in there is the license comment). We generate child themes from a base theme for our projects which a pretty extensive base structure and it's currently all in a single global library, that library includes 22 CSS files split into base/component/layout/theme CSS groups. Specifically in this demo theme, the CSS files in the theme group, one for media all, one for media print are empty. Already on HEAD this somehow results in an empty print CSS that's also actually being loaded. I think something is off here unrelated to this and possibly a bug on our side, I don't think that should be empty.

But unrelated from that, if I understand this correctly, this splits the single library into different files due to having multiple CSS groups? Is that the expected behavior? Is that because it optimizes for a scenario where multiple libraries have the same unique css aggregate string and it needs to split in case they have mixed groups? Is using multiple groups in a single library discouraged/a bad idea or is this something that should be optimized in this implementation?

catch’s picture

The problem with CSS groups is they add increments of 100 weight between the files. So if you have any other libraries at the same weight/category, it's easy for that library to end up between the files in the 'unique' library.

Because we can only generate a 'unique aggregate' for a coherent library (or more than one library), supporting multiple categories in a library would dramatically increase the likelihood that we have to fall back to the current behaviour. This is also the reason I had to split files out of umami global into a new library and add it as a dependency.

If we didn't have that problem it would be absolutely feasible to remove the category split but I got pretty useless results without it.

I really, really dislike the weight and group/category in the aggregation system and am not convinced it benefits us at all. Also pretty sure core themes and especially modules are not applying the SMACSS categories consistently either so we're not even working against how it's supposed to be. But untangling that really needs an overhaul with involvement from people who actually write lots of CSS.

edit: see #3046636: LibraryDiscoveryParser overwrites theme CSS group ordering for an example of the current inconsistency and also #1924522: Remove separate CSS_AGGREGATE_THEME aggregate file.

berdir’s picture

Some findings/thoughts from more testing:

* drupal.progress, due to it's negative weight on the css file, ends up being its own aggregate in front of system.base when loaded
* putting everything in our global theme library into the theme group reduced the total number of aggregates from 8 to 4. So I think a result of this is that it's definitely recommended to avoid multiple groups when using this feature and that's worth documenting.
* investigating and optimizing this isn't trivial. I ended up setting a breakpoint in \Drupal\Core\Asset\CssCollectionOptimizerLazy::optimize. it unsets the items, so a wrapper of that wouldn't be able to access that information when returned. This could be a useful addition in webprofiler or so, to report on how many aggregates are on the page, what's in them and how big they are. maybe the css_assets loop could use a protected method just to make it easier to allow insight into this before the cleanup in a subclass?
* On the JS side, I for example get jquery.min.js in a separate library, that's already the case. I'm also getting one for for once.min.js (again, already the case due to preprocess false), and then also the drupal aggregate with drupal settings. wondering if it's worth merging once.min.js and the drupal aggregate instead of the preprocess false now?
* will need more time to understand the JS side, see quite a few asset sets that are separate, such as loadjs, tabbable and progress.js both being in their own separate thing. They're all dependencies of drupalSettings, which is in the drupal group, maybe we need to add them to the drupal unique key explicitly to put them together?

catch’s picture

Thanks for looking at this more!

drupal.progress, due to it's negative weight on the css file, ends up being its own aggregate in front of system.base when loaded

Yes quite a few cases like this and I have no idea why they have such specific weights, we should open an issue for progress.css specifically to remove the weight.

putting everything in our global theme library into the theme group reduced the total number of aggregates from 8 to 4. So I think a result of this is that it's definitely recommended to avoid multiple groups when using this feature and that's worth documenting.

I rediscovered #1924522: Remove separate CSS_AGGREGATE_THEME aggregate file and newly found #3046636: LibraryDiscoveryParser overwrites theme CSS group ordering recently - for very old historical reasons, all files added by the theme are added after all module files anyway, so the groups within theme libraries more or less act like weights between those files, but that can be handled purely with dependencies and the ordering within the libraries themselves - when it's even necessary.

So we should probably move everything in themes into the 'theme' group, and then if we remove this 'themes always last' logic it would allow 'theme theme group CSS is always last' behaviour. There is still a lot of legacy baggage in this system dating all the way back to drupal_add_css() et all before we had dependencies at all, but we really need #1945262: Replace custom weights with dependencies in library declarations; introduce "before" and "after" for conditional ordering to clear it all out, and that issue is daunting.

I ended up setting a breakpoint in \Drupal\Core\Asset\CssCollectionOptimizerLazy::optimize

I've been doing dump($groups) at the bottom of CssCollectionGrouper::group() fwiw. That might be a place the webprofiler module could override too?

On the JS side, I for example get jquery.min.js in a separate library, that's already the case. I'm also getting one for for once.min.js (again, already the case due to preprocess false), and then also the drupal aggregate with drupal settings. wondering if it's worth merging once.min.js and the drupal aggregate instead of the preprocess false now?

So I nearly did that in the MR, but was a bit hesitant because the drupal and once libraries don't depend on each other. So someone could use one or the other and the other wouldn't be loaded. However, I think we can probably live with the following possibilities:

1. drupal + once
2. only drupal
3. only once

The same js would appear in a maximum of two different aggregates, which is a lot less than if we don't specify a unique library at all where there can be a very high number of variations. And most pages tend to have both anyway, then we save an http request for a very tiny file.

loadjs, tabbable and progress.js both being in their own separate thing. They're all dependencies of drupalSettings, which is in the drupal group

I think you might have misread the drupal.ajax dependencies as being for drupalSettings (because it also specifies some drupalSettings defaults just above) - saying this because that's what I just did checking this before I realised.

I think we can probably add loadjs to the htmx group (anticipating the AJAX library being deprecated eventually). It could theoretically be used without htmx but in practice it won't 99.9% of the time.

catch’s picture

progress.module css weight was added in #1924436: Remove separate CSS_AGGREGATE_SYSTEM aggregate file and fix drupal_add_library() to aggregate CSS properly before we had library dependencies or SMACSS groups. So could definitely use an issue to remove that.

I tried making some of the js library changes from #27 but they get caught by this test:

1) Drupal\Tests\demo_umami\FunctionalJavascript\AssetAggregationAcrossPagesTest::testNodeAddPagesAuthor
Asserting ScriptBytes 5739471 is greater or equal to 3693094 and is smaller or equal to 3694094
Failed asserting that 5739471 ( is equal to 3693094 or is greater than 3693094 ) and ( is equal to 3694094 or is less than 3694094 ).

/var/www/html/core/tests/Drupal/Tests/PerformanceTestTrait.php:637
/var/www/html/core/tests/Drupal/Tests/PerformanceTestTrait.php:686
/var/www/html/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php:106

Didn't investigate why yet but that's exactly the sort of unexpected regression that test is supposed to detect - probably a mixture of different dependencies between those pages messing things up, or might be to do with file ordering.

berdir’s picture

> The same js would appear in a maximum of two different aggregates, which is a lot less than if we don't specify a unique library at all where there can be a very high number of variations. And most pages tend to have both anyway, then we save an http request for a very tiny file.

Yeah, that was my idea. We get at most 3 variations of those 2 files and save one request when both are used. Not sure if the regression in the test happens just with this change or when you also tried to do the drupal ajax dependencies bit.

> I think you might have misread the drupal.ajax dependencies as being for drupalSettings (because it also specifies some drupalSettings defaults just above) - saying this because that's what I just did checking this before I realised.

Yes, you're right, it's ajax dependencies. Fine if we don't look into this bit further for now, we can revisit this when at least in core, we no longer depend on drupal.ajax. And if someone cares enough, it can probably be optimized for a specific site in a library alter hook.

> I've been doing dump($groups) at the bottom of CssCollectionGrouper::group() fwiw. That might be a place the webprofiler module could override too?

You're right. There's some extra processing in \Drupal\Core\Asset\CssCollectionOptimizerLazy::optimize() but yes, that has no impact on how many groups there are, just how exactly the resulting URL looks (aggregated/processed or not), that doesn't matter for this.

berdir’s picture

I created #3568157: Remove explicit weight from CSS files in core.libraries.yml and verified that removing the weight from progress.module.css and ajax-progress.module.css eliminates the extra aggregate. And on another page, the same applies to resize.module.css. means they are now after system/base, but that really shouldn't matter?

catch’s picture

Problem in #28 is afaict due to #3516264: CKEditor 5 loads all plugin translations on AJAX operations not having been a complete fix. Got a new MR up on there that should be, once that's (back) in we can try again here.

catch’s picture

With #3516264: CKEditor 5 loads all plugin translations on AJAX operations the 'htmx' group is doable without blowing things up here.

nicxvan’s picture

One thing I'm wondering is if there is a set of instructions we can create for site owners to optimize their libraries once this lands?

catch’s picture

@nicxvan we can recommend that themes add unique_css_aggregate: true to any of their libraries that they specifiy theme.info.yml as long as those libraries don't mess around with individual library weights.

I don't think we can much else though until we've fixed more issues in the asset system as well as cleaned up individual libraries like views.module

berdir’s picture

@catch. global libraries that would be 'every page' now, and not true, correct?

I think we're still figuring out what the most optimal groups and approaches are here, but I'd say there are a bunch learnings that we can document here.

I can try to draft a change record that covers some of the things we discussed here. there's the bit about not using multiple css groups in themes for best results with this, the notes in #12, some hints for when contrib projects should use this (essentially #12). I'm for example not sure yet if we should also recommend contrib projects to use 'every page' for libraries that they add on every page and when exactly (not when it's controlled by permissions probably, but it's ok if it's depending on a single global setting for a feature that's either enabled or not). Or maybe that's not worth the trouble and risk of incorrectly adding it.

catch’s picture

Yeah 'every page' is a tricky concept, working on this issue I kept thinking back to #769226: Optimize JS/CSS aggregation for front-end performance and DX and we're still trying to solve the same problem of asset duplication between aggregates now.

Technically theme libraries aren't every page because it depends on the theme too. Maybe we should use 'global'?

catch’s picture

Another thought, because we can't 100% guarantee a 'unique_css/js_aggregate' should we rename the key to 'css/js_aggregate_target' - that covers when a target aggregate is different between pages (one library on one page, two on another) and also when we have to fall back to current behaviour because another library is adding its files in the middle for whatever reason.

and when exactly (not when it's controlled by permissions probably, but it's ok if it's depending on a single global setting for a feature that's either enabled or not)

I think this is pretty much the guideline. Something that's controlled by permissions could still be put into its own aggregate if desired.

Permissions wouldn't be the end of the world because at least all anonymous users get the same thing, or all authenticated users with no additional roles, so you could still end up with good hit rates depending on traffic.

The biggest risk is a module adding every_page to a library that's not added on every page at all, an example would if views did it on views.module.css which is added on 'every page with a view', then it would mess things up quite badly because not every page does. Although even that situation would probably still have higher hit rates than now.

berdir’s picture

+1 to rename to key to target or identifier or something like that instead of unique.

Also fine with global. It's not every page, that's correct, the aggregate URL always includes the current theme and language, so it's always varied by this anyway.

And yes, even if there are a handful of variations in the end on the global aggregate target, it's definitely still better than what we have now. The one thing to consider I think is that in most cases, the biggest improvement will be putting the global theme libraries into the the global group. But, how many existing sites and themes are going to bother doing that, 10%? I think it might be worth exploring setting this automatically in a follow-up. Something like if extension-is-theme and library-is-in-global-libraries and aggregate target is NOT set, then set it to global? we could support explicitly setting it to FALSE or NULL to opt out if there really is a use case for that.

catch’s picture

Status: Needs review » Needs work

extension-is-theme and library-is-in-global-libraries and aggregate target is NOT set, then set it to global?

Yes that would be a good follow-up, we could do it in a library_info_alter hook in system module or similar so that it's not baked into the library discovery logic itself.

we could support explicitly setting it to FALSE or NULL to opt out

This is already in the case in the MR.

Moving this to needs work for the key rename to 'css/js_aggregate_target' + switching every_page to 'global'.

catch’s picture

Issue summary: View changes
catch’s picture

Pushed to update the naming.

There's a problem with css_aggregate_target and #3563782: [PP-1] Use early hints for CSS.

Depending on what other libraries are on the page, the libraries using the same target might be together in one aggregate or split into multiple.

At the point where we'd send early hints, we wouldn't have every library on the page, only the pre-placeholder attachments.

So if we have css_aggregate_target: true for a library we know that the early hint will match the eventual URL, but if it's css_aggregate_target: 'global' then it might get split by other libraries later and the early hint is no use. This would only be the case where a category is split, we can build the per-category URLs fine.

Once we've got rid of per-file weights etc. this becomes much less of a problem, maybe we can do something to try to get the libraries as close to each other as possible otherwise too - e.g. order by aggregate target after everything else.

Early hints potentially allows us to significantly speed up page rendering for cold/cool cache situations, so it would be a shame to lose this.

Doesn't matter for js because we don't particularly want to use early hints for those anyway (or not as far as I'm aware so far).

edit: we can always change the library definitions for early hints, it's an improvement here, so might as well go ahead then review later. But a good reason not to explicitly document the 'global' css aggregate target just yet.

catch’s picture

Status: Needs work » Needs review

Version: 11.x-dev » main

Drupal core is now using the main branch as the primary development branch. New developments and disruptive changes should now be targeted to the main branch.

Read more in the announcement.

catch’s picture

Just had another look at #3498793: Render assets from placeholders separately, abandoned it again, but realised something else here which is a lot easier than that issue:

If we always try to make an aggregate using the list of libraries for that aggregate (e.g. when libraries aren't split between more than one aggregate due to weights), then the same URL-building mechanism works even when css/js_aggregate_target isn't set.

This resolves a very common situation with the current logic (in HEAD and so far not fixed by this MR):

Request A generates aggregates

1 library/a

2 library/b library/c

3 library/d

Request B generates aggregate 1, 2, 3, and 4 - for simplicity let's assume there is one additional library added on request B and that ends up in aggregate 4.

1 library/a

2 library/b library/c

3 library/d

4. library/e

In this case, at the moment aggregates 1, 2, and 3 will result in the same file on disk, so the server doesn't have to do extra work, but with different query strings (because we send the full list of libraries from the request in the query string including the one in aggregate 4 on the second request - so request one gets library/a, library/b, library/c, library/d in the 'include' query string, request two gets library/a, library/b, library/c, library/d, library/e ), resulting in CDNs/browsers re-downloading exactly the same content.

With the new approach here, 1, 2 and 3 can have identical query strings because we only need the libraries contained within the individual aggregates to build them - as long as they can be built identically using that list, which we already implemented here with the $seen_index stuff.

The css/js_aggregate_target logic still helps too - because that guarantees stability across a lot higher number of pages, and when some aggregates use this, the other ones on the page become slightly smaller and more likely to be self-contained, it's also still the only way we can accurately use early hints for CSS.

All the logic to support this already exists in the MR, we just need to remove an if statement from the js/collection groupers pretty much.

This reduces the amount of CSS downloaded in the across pages performance tests up to 1/2 and 2/3 - 180kb reduced to 60kb in one case.

On top of that, #1945262: Replace custom weights with dependencies in library declarations; introduce "before" and "after" for conditional ordering should actually remove the library-split-across-aggregates problem entirely, to the point where we might be able to make this behaviour the only one we need to support (only in a follow-up to that issue though so a couple of steps past this one).

catch’s picture

Pushed that change. Only ran/updated the across pages performance test, it's possible other ones might break.

catch’s picture

In theory #44 works, in practice CSS groups and weights make things too fragile.

It seems to be working for js though so kept the idea there. Probably needs to be a follow-up for CSS and depend on custom weight removal.

nicxvan’s picture

Is it because there are so many css files compared with js files? So the combinatorics are just more complex?

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new8.58 KB

The Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

catch’s picture

There's two things I ran into:

Because of CSS categories, if we don't want loads of aggregate files, we need to add support for multiple categories sent in the URL, I got this working fine I think, although it's a bit fiddly (make it categories instead of categories, send a URL query string compressed array, change the validation).

The issue I got stuck on though was minute differences in the library ordering.

Let's say the main request has libraries A, B, C, D E, F, G H, I, J K, L.

A specific aggregate contains all the files from A, C, F, H and I.

So we pass A, C, F, H, and I in the 'libraries' query parameter. They don't have any interdependencies on each other. In this case we're passing in the libraries in the order the file appear in the aggregate, not necessarily the order the libraries were originally retrieved from #attached.

When we build the file list in the asset controller, there are two choices:

1. The current logic in the MR - take the library list, load the dependencies, set the dependencies that aren't in the list, and pass this to AssetController:getCssAssets(). Now you still have the full list of libraries that appear in that specific aggregate, but without the context of all the libraries in the request. Because there is no particular ordering between say libraries C and F, you can end up with C before F or F before C, and then the hash comparison fails - likely because some other library that's not in the list results in that order as a side effect.

2. Add an extra argument to ::getCssAssets()/::getJsAssets() to prevent library dependencies from being loaded, this allows the library list to be taken verbatim. When I added this, it fixed some aggregates that were failing the hash comparison, but broke others. That's the point where I gave up for the day. Might revisit this when I have more time because I can't really see why it wouldn't have worked, so there could have been a silly mistake somewhere.

When we explicit set css/js_aggregate_target on a small number of libraries, they're just not subject to the same ordering side effects in the same way, although probably not immune from it either. What we need to make it all work smoothly is that regardless of the superset of libraries, a subset of libraries will be ordered the same way, which requires #1945262: Replace custom weights with dependencies in library declarations; introduce "before" and "after" for conditional ordering.

catch’s picture

so there could have been a silly mistake somewhere.

In the clear light of day, I may know what this was... unfortunately I discarded some of the work because it felt like a complete dead end, but there was not that much of it.

catch’s picture

OK I think I put enough back in, with the silly mistake fixed, to confirm it was a silly mistake. So will now try to put the rest of it back.

catch’s picture

Approaching this incrementally - going to get to green with aggregate-per-CSS category, once it's fully green, add multiple-category CSS aggregates in, if that goes green, then it'll be #44 but not broken, although probably with different performance test results when it actually works.

catch’s picture

https://git.drupalcode.org/project/drupal/-/merge_requests/14203/diffs?c... shows the effect of automatically trying to use the libraries query string for single-category groups - it's reducing the total bytes downloaded in the across pages test by 10s of kilobytes on some of the tests, at the cost of more files.

If multiple categories works out, then we get the same kb saving for the same number of files as before applying the automatic libraries logic. And then it should be possible to build open it over time too.

catch’s picture

MR is green again with multiple category support, but there are problems:

- it's complex, we need to send the library + categories each specific library in the URL to get the right list
- once it all works correctly, so many libraries have weights in them that the overall bytes savings are not very dramatic - it can't apply to that many aggregates
- I was able to break it again by adding css_aggregate_target: global to Claro's global library, didn't try to track down that bug but shows it's still fragile.

Going to open a follow-up for it and move the code over there, and revert it here.

catch’s picture

Status: Needs work » Needs review
Related issues: +#3570051: [PP-1] Multiple category support for library-specific aggregates

Opened #3570051: [PP-1] Multiple category support for library-specific aggregates and reverted those commits here. Proves that it can work, or at least nearly work, but not necessarily that it should.

Switched back to only using the libraries/category strategy for libraries that specify a target aggregate.

Moved the category validation logic to the css asset controller since it doesn't need to be in the base class.

Added a target aggregate to the Claro global CSS - this shows a nice improvement in the across pages performance test since we no longer have to download Claro's CSS three times when viewing the node/add form for three different node types.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new91 bytes

The Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

catch’s picture

Status: Needs work » Needs review
berdir’s picture

Status: Needs review » Needs work

The ckeditor issue is in, so this needs another update already.

catch’s picture

Status: Needs work » Needs review

Squashed a load of commits because there were various wrong turns here so this will be easier to rebase in future.

catch’s picture

Rebased.

catch’s picture

Rebased.

grimreaper’s picture

Hi,

First, thanks all for your very deep work on this issue!

From catch's comment 25:

I really, really dislike the weight and group/category in the aggregation system and am not convinced it benefits us at all. Also pretty sure core themes and especially modules are not applying the SMACSS categories consistently either so we're not even working against how it's supposed to be. But untangling that really needs an overhaul with involvement from people who actually write lots of CSS.

I agree with that. I have rarely seen people using group (SMACSS) properly on projects. Most of the time, all CSS files end in the theme category.

There are currently a lot of mechanisms for managing/altering the order of loading/processing:

inside a library (unordered):

  • group, for CSS only (SMACSS)
  • weight property (to be deprecated...)
  • order of declaration
  • maybe more...

in between libraries (unordered):

So, with all those combinations, optimizing the current system may be hard to understand both by us the maintainers, and by the module & themes owners who are expected to use those mechanisms.

That doesn't mean adding a new mechanism, like the one proposed here, must be avoided, especially with such impressive documented performance gains.

Maybe the issue summary should contain some guidelines of new best practices. Also currently it mentions unique_css_aggregate and unique_js_aggregate, which are no more in the MR.

catch’s picture

Issue summary: View changes

There are currently a lot of mechanisms for managing/altering the order of loading/processing:

So one thing with this issue/MR is it doesn't allow you to change the order that assets are loaded in. If files in a library have individual weights and end up getting split between multiple aggregates, it will silently ignore the css/js_aggregate_target to preserve order. The only thing it allows you to tweak is the dividing line where aggregates start and finish.

This also means that while it's the internal implementation rather than API, once we've deprecated weights, we may be able to generate every aggregate using the libraries query parameter instead of delta/exclude/include which should then further improve cache hit rates, without changing the number of aggregates very much or with any library definition changes. When we don't have individual file weights, every library will be 'intact' in that different files from different libraries won't be between each other in the list, so an aggregate can then be built purely from the list of libraries in it (and categories for CSS) without knowledge of all the other libraries on the page.

This is quite far off though because we'll need to remove weight support not just deprecate it in order to do that.

I added some usage notes to the issue summary - does that help a bit?

edit: and also a change record.

catch’s picture

pdureau’s picture

This is quite far off though because we'll need to remove weight support not just deprecate it in order to do that.

So we add first a parameter (making the full mechanism a bit more complex) in order to remove another parameter later (which is making a mess, so the full mechanism will be simpler at the end). That sounds good :)

And performance gain (confirmed by tests included in the MR) for only 5% increase of core/lib/Drupal/Core/Asset codebase (1491 to 1576 LOC).

However, I am not sure having properties (css_aggregate_target & js_aggregate_target) with dual typing is a good practice. Is it?

And I am doubting theme owners will use those 2 new properties. But if it is useful for modules, especially the ones from Core (as already applied in the MR) and the "heaviest" from Contrib (can we find a way to identify the ones which may benefit the most of this and make their maintainers aware of the new API? ).

catch’s picture

However, I am not sure having properties (css_aggregate_target & js_aggregate_target) with dual typing is a good practice. Is it?

We could split this into 'css_unique_aggregate' (e.g. 1-1) and 'css_aggregate_target' (arbitrary string) and the same for js. I'm not sure having two properties that need to be documented side by side, and which are mutually exclusive, is easier than the dual typing though.

Originally I only supported TRUE, and doing that is quite a bit more simple too, but it closes out some possibilities with libraries that have complex dependency chains - especially once the before/after issue is in which will make a lot more libraries potential candidates for this.

And I am doubting theme owners will use those 2 new properties.

Assuming this gets in, I'll immediately open an issue for Mercury to adopt it. Some of the bigger base themes might too. It will obviously take a lot longer to filter down into custom themes but at least they'll have the option, and there is a chunk of sites that care very much about core web vitals and page loading performance in general.

Tentatively tagging for 11.4.0 release highlights.

grimreaper’s picture

I talked about this issue with @berdir at yesterday community event at DDD. Much more clear for me now, thanks!

I thought about something, will it also be possible to use this feature with components declared libraries?

catch’s picture

@grimreaper, I don't think there's a way to do that, but also think it should be a follow-up. We might be able to do something like support the same declaration at the top level of foo.component.yml (e.g. next to where you can manually specify js/css). But there's no 'library' level with css/js nested underneath it so doesn't look like we can just take whatever's in there.

berdir’s picture

We specifically talked about "And I am doubting theme owners will use those 2 new properties." yesterday and I pointed out that it is specifically helpful for themes with a large global theme library, because that will then always be isolated and always be loaded as a single separate aggregate on all pages, significantly reducing the size of total aggregate across multiple pages.

We can put that in starterkit, mercury and docs and then theme owners will IMHO automatically use it for that as they copy an example. Anything on top of that for additional optimization gets more complicated but it will have a much smaller impact and will be for those sitess and modules/themes that want to optimize for those few extra kb.

grimreaper’s picture

Ok, as components libraries are added from core/lib/Drupal/Core/Asset/LibraryDiscoveryParser::librariesForComponents(), I thought updating core/lib/Drupal/Core/Theme/ComponentPluginManager::libraryFromDefinition() to handle those new keys would have done the trick.

Good suggestion for the documentation :)

catch’s picture

@grimreaper oh I think that's the place to add support but I think we'd need to explicitly add the support, afaik it won't be handled automatically with the current MR, although also not sure about that.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new91 bytes

The Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

catch’s picture

Status: Needs work » Needs review

Rebased.

catch’s picture

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new91 bytes

The Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

catch’s picture

Status: Needs work » Needs review

Rebased.

catch’s picture

I've pushed a commit to fix the tests but it hasn't arrived in the gitlab UI after several hours yet. Tried rebasing differently to poke things again.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new91 bytes

The Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

catch’s picture

Status: Needs work » Needs review

Rebased.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new98 bytes

The Needs Review Queue Bot tested this issue. The merge request has merge conflicts and cannot be merged. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

berdir’s picture

Trying to get back into this and read up on our previous discussions.

The ckeditor UMD issue just landed that you identified as a blocker for optimizing ckeditor in regards to this, but I think a follow-up for that is still good, don't need to do everything here.

#1945262: Introduce "before" and "after" for conditional ordering in library definitions is close hopefully, would that change how umami is handled with the global_tweaks library? Not sure on how to best handle umami, it's just interesting because that's what most of our performance tests are run against, but need to find a balance, discussions on how to best optimize and structure a basically-hidden install profile shouldn't hold up getting this feature in.

There's also some notes in #35 and earlier on what could also be useful to add to the CR, and as mentioned in the MR, some clear guidelines on what themes should do at least for their global libraries would be helpful.

berdir’s picture

One more thing. I know very well how much work it is to rebase these performance MR's, but this no longer applies on 11.x, which makes it harder to test in real projects. A draft 11.x MR, maybe without performance test adjuments would be helpful for that.

catch’s picture

Replied to a couple of points inline.

Ckeditor - will open a follow-up. Originally I was expecting we'd be using importmaps, no idea how the umd approach affects this yet but will be interesting to find out.

Umami and before/after yes we might be able to get rid of the tweaks library if we can drop the weight of the one file. It's also not clear to me why it uses separate CSS group for only one file, if we changed the group to match the others, that would be one less aggregate. Really think the CSS groups are more or less arbitrary and only used as a proxy for weight inconsistently.

Adding 'global' to the change record, I really think we should recommend only 'true' for theme global libraries. And if they have multiple global libraries they could merge them down to one. All of this will get easier when weights are gone. Using 'true' can result in more files but it's guaranteed to result in zero duplication.

catch’s picture

Status: Needs work » Needs review

Rebased again and tried to answer @berdir's questions on the MR. I don't think we should document 'global' in the CR because it's at best a convention and it might be one we eventually discourage (e.g. if we can refactor theme global libraries down to a single library).

catch’s picture

StatusFileSize
new50.25 KB

For #82 I'm uploading a .diff with the tests hacked out, this still applies with fuzz with patch -p1 against 11.x. Would prefer to start a backport MR when this has actually been committed to main given it needs a rebase approximately daily and I don't want to do that for two MRs that also need to be kept in sync with each other.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new98 bytes

The Needs Review Queue Bot tested this issue. The merge request has merge conflicts and cannot be merged. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

catch’s picture

Status: Needs work » Needs review