Problem/Motivation

#1014086: Stampedes and cold cache performance issues with css/js aggregation changes aggregate URLs to a hash + query string with theme, delta, language and libraries.

The libraries list is the 'minimum representative set' (i.e. leaves of the dependency tree that are not dependents of anything else), but can still get quite long. user/1 visiting the front page of the standard profile ends up with query strings nearly 600 chars long. That gives us some headroom before it reaches 1,000, but it's not indefinite.

There is no RFC limit on URL or query string length, and we no longer support old versions of IE that used to have an arbitrary limit of around 2,000, but it's still possible for servers to set a maximum length (for example see discussion in https://stackoverflow.com/questions/812925/what-is-the-maximum-possible-...).

The most likely way someone would run into this is a site with 300 modules on quite a restrictive hosting platform, which is... well it's very possible really even if it'll be relatively uncommon.

@alexpott suggested compressing the query string if it's going to go over 950 characters.

Steps to reproduce

Proposed resolution

There are a couple of ways we could do this:
1. In the original issue, before we had the 'minimum representative set' option for libraries, #1014086-100: Stampedes and cold cache performance issues with css/js aggregation suggested keeping a lookup table, using a base64 increment or similar, so that each library could be represented by just one or two characters in the query string. We'd have to build that lookup table on library discovery and consult it both when building URLs and aggregates. Somewhat complex to implement but would be extremely efficent for the actual URL length which would end up looking something like A%2c0%2cf%2cbA instead of drupal/once%2cdrupal/backbone%2cdrupal/ajax

2. @alexpott suggested using gzcompress(), i.e.:

strlen(base64_encode(gzcompress("contextual/drupal.contextual-links%2Csystem/base%2Colivero/global-styling%2Ccore/drupal.active-link%2Colivero/powered-by-block%2Colivero/feed                                                                                                                                                       /drupal.debounce%2Ctoolbar/toolbar%2Cuser/drupal.user.icons%2Ccore/shepherd%2Ctour/tour-styling%2Ctour/tour%2Ccore/drupal.tabbingmanager%2Ccontextual/drupal.contextual-tool

This reduces 544 characters => 324 characters.

I think we should go with #2, at least until there's a compelling reason to go with #1, because #2 will be trivial to implement, and #1 will be hard.

A further complication is we've been hoping to use the URL information for ajaxPageState: #3279206: Dynamically determine ajaxPageState based on libraries. I guess if we're only ever going to pass that list back to PHP at some point, maybe it's fine to just pass the encoded list around? But is it that simple?

Remaining tasks

User interface changes

API changes

Data model changes

Release notes snippet

CommentFileSizeAuthor
#42 3303067-42.patch13.94 KBcatch
#42 3303067-42-interdiff.txt722 bytescatch
#38 3303067-39.patch13.93 KBcatch
#38 interdiff-39.txt1.97 KBcatch
#37 3303067-37.patch14.94 KBcatch
#37 interdiff-37.txt5.09 KBcatch
#33 3303067-33.patch15.8 KBcatch
#33 interdiff-29-33.txt2.51 KBcatch
#30 3303067-29.patch15.71 KBcatch
#29 3303067-29.patch15.71 KBcatch
#29 3303067-24-29-interdiff.txt2.06 KBcatch
#24 3303067-24.patch15.45 KBcatch
#24 3303067-interdiff-18-23.txt3.09 KBcatch
#18 3303067-18.patch15.32 KBcatch
#18 3303067-interdiff-18.txt3 KBcatch
#16 3303067-16.patch15.48 KBcatch
#15 3303067-15.patch15.48 KBcatch
#15 3303067-interdiff-15.patch5.38 KBcatch
#14 3303067-14.patch13.23 KBcatch
#13 3303067-13.patch14.29 KBcatch
#13 3303067-interdiff.txt11.64 KBcatch
#9 3303067-9.patch18.68 KBcatch
#8 3303067-8.patch18.67 KBcatch
#7 3303067-7.patch18.46 KBcatch
#7 3303067-interdiff.txt1.9 KBcatch
#6 3303067-6.patch15.65 KBcatch
#6 3303067-interdiff.txt12.76 KBcatch
#5 3303067-5.patch17.74 KBcatch
#4 3303067.patch9.52 KBcatch
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

catch created an issue. See original summary.

nod_’s picture

We can also add a new data attribute on the script tags that have this information in clear format so we don't need to mess around with url parsing.

nod_’s picture

I think it'd be that simple. We don't use it in the frontend, it's only to send back to php.

catch’s picture

Title: Compress aggregate URL query strings if they're over 950 chars » Compress aggregate URL query strings
Status: Active » Needs review
FileSize
9.52 KB

Took a look at this.

I started off trying to implement compression only when the list was going to be over 950 chars, but then wondered if we really need the two code-paths:

We need to deal with both include + exclude, this does increase the risk that the query string length will get very long, since any limits will be for the entire string rather than each part, if we compress over 475 chars that's quite a low threshold, and if we start checking if both strings are there etc. that seems a bit overkill.

The main reason to conditionally compress would be to save some CPU cycles, and maybe to make debugging easier, however this is all behind caching, and if we move some code around, we do the compression once per asset type, not once per asset group. I'm also not sure two different formats helps debugging that much over a shorter code path.

Additionally, given the query string is on every asset URL, compressing is going to save some bytes on the HTML page itself - say we've got a 550 char string that's on four js assets and four CSS assets, that's 2,200 characters, compression gets that down closer to 1,000.

This has all given me another idea too, but uploading a theoretically working patch before trying that.

catch’s picture

Alright the more comprehensive version seems like it might be a goer. No interdiff because it's more or less an entirely new patch, but here's what's different.

The one new constraint is that this now needs to handle a nested array, so we can't use implode()/explode(). We also can't use php serialize() because it's user data, so using json_encode()/json_decode() for that.

The 'include' and 'exclude' query parameters potentially have a lot of strings in common, since we might have to compress both, we might as well compress them into a single string. Compressing one slightly larger string should be more CPU efficient than compressing two smaller ones.

We can also include the theme in there, since that's common to every URL and similarly may contain duplicate strings (i.e. 'olivero' provides a couple of 'olivero/foo' libraries).

We also drop the separate 'include/exclude/theme' query argument keys, and chuck all this in a single 'data' key, which doesn't save a lot but slightly offsets the extra characters from using json_encode().

Comparisons of a real example (front page of a 10.1.x standard install as user/1 again):

HEAD:

css_AI3tdfWguXKwGYQh77azpIWFB75vRNg_QczKMd7wJAo.css?delta=0&language=en&theme=olivero&include=contextual/drupal.contextual-links%2Csystem/base%2Colivero/global-styling%2Ccore/drupal.active-link%2Colivero/powered-by-block%2Colivero/feed%2Cviews/views.module%2Colivero/navigation-secondary%2Colivero/search-wide%2Colivero/navigation-primary%2Colivero/search-narrow%2Ccore/modernizr%2Ccore/drupal.debounce%2Ctoolbar/toolbar%2Cuser/drupal.user.icons%2Ccore/shepherd%2Ctour/tour-styling%2Ctour/tour%2Ccore/drupal.tabbingmanager%2Ccontextual/drupal.contextual-toolbar%2Cshortcut/drupal.shortcut%2Ctoolbar/toolbar.escapeAdmin%2Cbig_pipe/big_pipe

(638)

Only compressing 'include', and using implode():

css_AI3tdfWguXKwGYQh77azpIWFB75vRNg_QczKMd7wJAo.css?delta=0&language=en&theme=olivero&include=eJx9kVFuxCAMRC8E4Qw9SWXATawSjGxImp6%252BZLdktdWqP2A8b%252BQBAueKX7VBclFagTSFq2MT5U81emjF1XlQNJxoQ2E3J%252FYd0Hp0ZjaBBYcfQu3MzXvhhXcUjNYf1icOD%252BEDMZqNcFd3W6eVY0uPORk2mqESZ6vYk0WQ4xIVQcJid4ovDUVofYFnEOH9HrlPQ8n0LU83iOi55YCmMicP4n530xRlQGc9UY%252Bkd68uWBaU2E3tdDS5XufqPE2p4H1XV8gw4yn98xEjgC4sNbQ6kHH%252Bm3RCDVDwLa6Ujaf5vVBBN4ofzJrDDw%253D%253D

(438)

Newest approach:

css_AI3tdfWguXKwGYQh77azpIWFB75vRNg_QczKMd7wJAo.css?delta=0&data=eJx9UstOxDAM%252FJecaXvnxndQhJzEtBFpHNnJloL4d9yy231o4RR7PDO2k3yZCGmoMKB5NJjMgykjTmtCMRyQSZGQXKxesWfjKBX8KBVi33muGWJ7hpoY0ruoQBYpOPWdBUFNj059N0SySpOyKHPQiiPG3QhcUd5mcinKNCOjb%252BzS2EjuqvaG6DU%252FBJyl77ajncjXeNU2wSEMUAKlRlDH9cDLZV0Q2I3NHPxfssxhui9KwEzzvoo2R07hk2%252BX82ipJrc2KETRAvfdMVCoCvJOXZM26Jyym8iIeUT2m7pu0soX93gGb%252FsWsFY5EyR94t%252Fqfy94HklG4uJq2Vkn4M4GLYqDjE9%252BCusPsmF4zSHrEKfIvHz%252FABr94Yc%253D

(477)

So this results in a slightly longer compressed string, but it's more robust against a string with both include and exclude query parameters.

One more possible approach here, but again going to upload progress.

catch’s picture

One more iteration.

Instead of using json_encode()/json_decode(), have the new UrlHelper methods only accept a string.

However, we still compress all the bits of the URL we can - by custom encoding the array.

css_AI3tdfWguXKwGYQh77azpIWFB75vRNg_QczKMd7wJAo.css?delta=0&data=eJx9kWFuwyAMhS9Ewhl2ksmAl1gjGNnQLFMPX9KWRJ2q%252FQHj9z75AZiuHOmCwlfPqeBPqRBtkJohjmdniJS%252B1eimBRfrQNE8MTtFds2gZWueyXgW7Dz40jx39rBnXlEwDG4bXGR%252FCl%252BIwVwIV7X3dVw41HjOSXChCQpxGhRbsgCyHaIiiJ%252BHlcJbIAstb%252BwJRHh9RG7TUBL9yssNAjquyaMpzNGB2OduqqJ0016P1CLpg9UZ84wSGlR3osrxOkfnZUoB55q6QIIJd%252Bmfj%252BgBdGYpvpZu6ee%252FSUdUDxk%252FwkLJOJo%252BM2W0vbgBRirH2g%253D%253D

(437)

This results in a URL literally one character shorter than when we only encode 'include', but it'll be more robust when there's a list of libraries in 'exclude' and cheaper to generate. If someone wanted to use the compress/uncompress functions with json_encode()/json_decode() they still can - just call it on an array before passing in the string.

Part of the reason I wanted to try a slightly more generic approach is because there's a possibility we can use this for #956186: Allow AJAX to use GET requests too.

catch’s picture

catch’s picture

catch’s picture

Missed the cspell issue, rewording to get around it.

nod_’s picture

on umami frontpage with this URL:

js_PEdSRDCYlyswo_e1QJZX-ZKulvo_gfga7JWEGxn4yMU.js?scope=footer&delta=0&language=en&theme=umami&include=core/jquery%2Ccore/once%2Ctour/tour%2Ccore/drupal.progress%2Ccore/tabbable%2Ccore/loadjs%2Cresponsive_image/ajax%2Ccore/drupal.ajax%2Ccontextual/drupal.contextual-links%2Csystem/base%2Cumami/classy.base%2Ccore/normalize%2Cumami/demo-umami-tour%2Cumami/global%2Cumami/messages%2Cumami/webfonts-open-sans%2Cumami/webfonts-scope-one%2Ccore/drupal.active-link%2Cviews/views.module%2Cumami/recipe-collections%2Cumami/more-link%2Cumami/view-mode-card-common%2Cumami/view-mode-card%2Cumami/classy.node%2Cumami/view-mode-card-common-alt%2Ccore/modernizr%2Ccore/drupal.debounce%2Ctoolbar/toolbar%2Cuser/drupal.user.icons%2Ccore/drupal.tabbingmanager%2Ccontextual/drupal.contextual-toolbar%2Cshortcut/drupal.shortcut%2Ctoolbar/toolbar.escapeAdmin%2Cbig_pipe/big_pipe
  • Core: 860
  • patch #4: 528
  • patch #5: 575
  • patch #6: 553
  • Using ~ as a separator: 794

Not too happy about having the language, theme, include, exclude params as a set indexes of the data array, feels like a future headache.

One thing to be aware of is that using gzcompress makes it impossible to use from the JS (at least without using something like zlib which adds quite a bit of js just for that).

One thing we could do easily is replace the , separator by ~ because that one isn't urlencoded so we save 2chars for each separator, which can add up as seen above. I haven't seen a ~ in a library name declaration so that should be safe enough, we could always prevent people from using that character in the library definition.

Status: Needs review » Needs work

The last submitted patch, 9: 3303067-9.patch, failed testing. View results

catch’s picture

One thing to be aware of is that using gzcompress makes it impossible to use from the JS (at least without using something like zlib which adds quite a bit of js just for that).

This is true, but if we just need to pass it back to PHP, could the AJAX API just pass the encoded data and we deal with it in PHP again? We might need a proper generic API for PHP to get the relevant information in that case though.

Not too happy about having the language, theme, include, exclude params as a set indexes of the data array, feels like a future headache.

Yeah I kind of think #4 (but maybe with the compression API in UrlHelper like the latter patches) might end up being the way to go here.

catch’s picture

Status: Needs work » Needs review
FileSize
11.64 KB
14.29 KB

Not too happy about having the language, theme, include, exclude params as a set indexes of the data array, feels like a future headache.

Backed that out, new version which has the same logic as #4, but drops the LibraryCompresser class in favour of the UrlHelper methods from later patches.

Also improved the test coverage a bit.

Interdiff is against #4 since it's basically a clean-up of that approach.

Using ~ as a separator: 794

Wow that is a big difference, we might want to open new issue just for that change since it should be an easy one?

catch’s picture

Hadn't fully removed LibraryCompresser

catch’s picture

More test improvements and accounting for another failure condition - we don't want logs getting flooded if people put garbage in the 'include' or 'exclude' query parameters, since it's a client error not a logic error, so suppressing the warning and adding test coverage for it.

catch’s picture

whitespace.

nod_’s picture

Status: Needs review » Needs work
  1. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,47 @@ public static function buildQuery(array $query, $parent = '') {
    +    // Use json_encode() instead of serialize(), because this is used to
    +    // compress/uncompress user data.
    

    That comment is either unnecessary or in the wrong place.

  2. +++ b/core/modules/system/src/Controller/AssetControllerBase.php
    @@ -147,9 +151,19 @@ public function deliver(Request $request, string $file_name) {
    +      $attached_assets->setAlreadyLoadedLibraries(explode(',', UrlHelper::uncompressQueryParameter($request->query->get('exclude'))));
    

    could be explode(',', $exclude_string) here no?

  3. +++ b/core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php
    @@ -164,19 +173,21 @@ protected function replaceGroupHash(string $url): string {
    +    $include = explode(',', UrlHelper::uncompressQueryParameter($parts['query']['include']));
    +    $includes[] = 'system/llama';
    +    $parts['query']['include'] = UrlHelper::compressQueryParameter(implode(',', $include));
    

    that should be $include[] here no? (without the "s")

catch’s picture

Status: Needs work » Needs review
FileSize
3 KB
15.32 KB

Should address #17.

nod_’s picture

Status: Needs review » Reviewed & tested by the community

Still at 528 chars, all good. It makes sense that only compressing the include and exclude value the string would be the shortest since there are a lot or repetition inside that string "core/", "core/drupal.", "umami/", etc. so it checks out that it's the most efficient.

Tried to replace "," by "~", and the string ends up longer at 540 chars. So no need to open a follow-up for that since it never appear in the URL.

This will mean that if we use that include (and exclude) parameters for ajaxPageState, the backend will need to be able to receive a list of encoded arguments instead of the exhaustive list of libraries on the page. Not relevant to this issue and not a big deal in itself, something to keep in mind for BC and all that.

Given all that it's RTBC for me.

catch’s picture

I'm also hopeful this will help with #956186: Allow AJAX to use GET requests.

Wim Leers’s picture

Status: Reviewed & tested by the community » Needs work
  1. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,45 @@ public static function buildQuery(array $query, $parent = '') {
    +   * While RFC 1738 doesn't specify a maximum length for query strings,
    +   * browsers (such as Internet Explorer) or server configurations may
    +   * restrict URLs and/or query strings to a certain length, often 1000
    +   * or 2000 characters. This method can be used to compress a string into a
    +   * URL-safe query string which will be shorter than if it was used directly.
    

    🙈 Nit: 80 cols formatting

  2. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,45 @@ public static function buildQuery(array $query, $parent = '') {
    +   *   The uncompressed data or FALSE on failure.
    +   */
    +  public static function uncompressQueryParameter(string $compressed): string|bool {
    

    🤓 I was confused by "uncompress" — never seen that before! So did some searching and … apparently "uncompress" is rarely used, "uncompressed" is: to indicate that something is not/never was compressed.

    I think one usually uses "decompress" — just like "encode & decode".

    Sorry 😬

  3. +++ b/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php
    @@ -102,18 +115,9 @@ public function optimize(array $css_assets, array $libraries) {
    -    $ajax_page_state = $this->requestStack->getCurrentRequest()->get('ajax_page_state');
    -    $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : [];
    -    $query_args = [
    

    🤔 Why did these get moved to a different location in the code?

  4. +++ b/core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php
    @@ -200,4 +211,58 @@ protected function omitTheme(string $url): string {
    +   *   The URL with the 'include' set to an arbitrary string.
    

    s/include/exclude/

  5. +++ b/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
    @@ -106,6 +106,25 @@ public function providerTestInvalidAbsolute() {
    +    $this->assertEquals($data, $uncompressed);
    +    $this->assertLessThan(strlen($uncompressed), strlen($compressed));
    

    Wow, super elegant test coverage! 🤩

  6. +++ b/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
    @@ -106,6 +106,25 @@ public function providerTestInvalidAbsolute() {
    +    // Pass an invalid string to ::uncompressQueryParameter() and ensure it
    +    // doesn't result in a PHP warning.
    +    $this->assertFalse(UrlHelper::uncompressQueryParameter('llama'));
    

    🤓 Nit: this feels like it belongs in a separate test method.

catch’s picture

#21.2 so I nearly used ::decompress() here, but PHP uses gzuncompress() https://www.php.net/manual/en/function.gzuncompress.php and that's what we're using to compress/un-/de-compress, so I went for that. Also checked out uncompress and it appears to be more widely used for the specific case of compressed files, i.e. https://www.merriam-webster.com/dictionary/uncompress

So... I don't have a strong preference, but that was the thinking behind it. Do you still think it should be decompress?

#21.3 the query string is nearly identical for every aggregate, so this means we compress the same string once outside the foreach loop. It's behind a cache, but it doesn't hurt. If there was a suitable place we could even do it once between css and js since the list of libraries is the same, but there's no obvious spot to do that at the moment.

Wim Leers’s picture

Hah! That's more than good enough for me 🤓

catch’s picture

Status: Needs work » Needs review
FileSize
3.09 KB
15.45 KB

Should hopefully address #21.

nod_’s picture

Status: Needs review » Reviewed & tested by the community

Patch still applies and works. Review from #21 addressed.

All good :)

olli’s picture

  1. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,45 @@ public static function buildQuery(array $query, $parent = '') {
    +   * @see \Drupal\Component\Utility\UrlHelper::uncompress()
    

    @see uncompressQueryParameter()

  2. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,45 @@ public static function buildQuery(array $query, $parent = '') {
    +    return urlencode(base64_encode(gzcompress($data)));
    

    It seems to double encode unsafe characters from base64 encode (+, / and =).

    <script src="/sites/default/files/js/js_gi8MaXv8i2qj2W0p7yMIoHc3LqEQ2PXt8dRkQm4-wPg.js?scope=footer&amp;delta=0&amp;language=en&amp;theme=olivero&amp;include=eJxtjEEOwyAMBD8UypsMdolVgyObgvh9pB7oJZc97MyuL%252B9UYwKnQ4UHmcYimkCC9yXcyq4vnWSEIa2QRPNngzcRHoNpevzlqyp%252B5f%252FXYHCBztqCU9aGYGtDJ7B8hsn4OLiM64PewEznDVS%252FSdg%253D"></script>
    

    With "base64url" encoding the parameter would be few characters shorter. Something like this: https://www.php.net/manual/en/function.base64-encode.php#123098 ?

  3. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,45 @@ public static function buildQuery(array $query, $parent = '') {
    +   * @see \Drupal\Component\Utility\UrlHelper::compress()
    

    @see compressQueryParameter()

  4. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,45 @@ public static function buildQuery(array $query, $parent = '') {
    +    return @gzuncompress(base64_decode(urldecode($compressed)));
    

    do we need to add ext-zlib to composer.json for gz-functions?

catch’s picture

Status: Reviewed & tested by the community » Needs work

Marking needs work for #26, 'base64url' encoding is an easy change to make and worth saving some characters for.

catch’s picture

Status: Needs work » Needs review
FileSize
2.06 KB
15.71 KB
catch’s picture

Fixing the typo.

Wim Leers’s picture

  1. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,48 @@ public static function buildQuery(array $query, $parent = '') {
    +   * browsers (such as Internet Explorer) or server configurations may restrict
    

    Nit: I don't think we support the Internet Explorer anymore? 🤓 (Also: 🥳)

  2. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,48 @@ public static function buildQuery(array $query, $parent = '') {
    +    return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(gzcompress($data)));
    ...
    +    return @gzuncompress(base64_decode(str_replace(['-', '_'], ['+', '/'], $compressed)));
    

    Fascinating that we can uncompress even though = was omitted?! 🤯

  3. +++ b/core/modules/system/src/Controller/AssetControllerBase.php
    @@ -147,9 +151,19 @@ public function deliver(Request $request, string $file_name) {
    +    if (!$include_string) {
    +      throw new BadRequestHttpException('The libraries to include must be passed as a query argument');
    +    }
    

    🤔 This looks like the message for !$query->has('include'), but the message for ::uncompressQuery() returning FALSE?

    EDIT: ah, yes, the "has" check already happened above. We just need to tweak this message :)

  4. What length does this result in compared to @nod_'s analysis in #10?
nod_’s picture

some libraries in umami changed so went back to the same commit i used in #10 to have numbers we can compare, with latest patch we're at 502, we have a winner.

For reference, using commit 6a1855c2:

  • Core: 860
  • patch #4: 528
  • patch #5: 575
  • patch #6: 553
  • Using ~ as a separator: 794
  • patch #30: 502
catch’s picture

#31.1: removed the IE reference.

#31.2: this is because = is only used for right-padding the string and doesn't form part of the actual compressed data, so we're able to just strip it (note that it's replaced with ''). We could also have used rtrim() there but that'd mean more nested string manipulation. I added a comment.

#31.3: adjusted the comment to match the language for the 'exclude' error.

#31.4/#32: nice!

nod_’s picture

Status: Needs review » Reviewed & tested by the community

Seems like we're good to go now.

Wim Leers’s picture

Issue tags: +front-end performance

#33: TIL = is used for right-padding data in base 64 encoding! I actually always wondered what that was for, and never looked into it! 😅

Thanks for teaching me something! 🙏

#32: very nice!


RTBC++

Just realized one last thing 😬

What happens when you have cached responses (in Dynamic Page Cache, Page Cache, Varnish, CDN … — any reverse proxy) and that still contains uncompressed aggregate URL query strings? Does that still work?

Oh, HAH! #1014086: Stampedes and cold cache performance issues with css/js aggregation is 10.1.x-only, so … no existing sites are on this yet! And when updating to a new minor, caches are cleared already anyway 👍

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php
@@ -74,6 +74,19 @@ public function optimize(array $css_assets, array $libraries) {
+    // All asset group URLs will have exactly the same query arguments, except
+    // for the delta, so prepare them in advance.
+    $query_args = [
+      'language' => $this->languageManager->getCurrentLanguage()->getId(),
+      'theme' => $this->themeManager->getActiveTheme()->getName(),
+      'include' => UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries))),
+    ];
+    $ajax_page_state = $this->requestStack->getCurrentRequest()->get('ajax_page_state');
+    $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : [];
+    if ($already_loaded) {
+      $query_args['exclude'] = UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded)));
+    }
+
     $css_assets = [];
     foreach ($css_groups as $order => $css_group) {
       // We have to return a single asset, not a group of assets. It is now up
@@ -102,18 +115,9 @@ public function optimize(array $css_assets, array $libraries) {

@@ -102,18 +115,9 @@ public function optimize(array $css_assets, array $libraries) {
         $css_assets[$order]['data'] = $uri;
       }
     }
+
     // Generate a URL for each group of assets, but do not process them inline,
     // this is done using optimizeGroup() when the asset path is requested.
-    $ajax_page_state = $this->requestStack->getCurrentRequest()->get('ajax_page_state');
-    $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : [];
-    $query_args = [
-      'language' => $this->languageManager->getCurrentLanguage()->getId(),
-      'theme' => $this->themeManager->getActiveTheme()->getName(),
-      'include' => implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries)),
-    ];
-    if ($already_loaded) {
-      $query_args['exclude'] = implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded));
-    }

--- a/core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php
+++ b/core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php

+++ b/core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php
@@ -71,6 +71,21 @@ public function optimize(array $js_assets, array $libraries) {
+    // All group URLs have the same query arguments apart from the delta and
+    // scope, so prepare them in advance.
+    $language = $this->languageManager->getCurrentLanguage()->getId();
+    $query_args = [
+      'language' => $language,
+      'theme' => $this->themeManager->getActiveTheme()->getName(),
+      'include' => UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries))),
+    ];
+    $ajax_page_state = $this->requestStack->getCurrentRequest()
+      ->get('ajax_page_state');
+    $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : [];
+    if ($already_loaded) {
+      $query_args['exclude'] = UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded)));
+    }
+
     // Group the assets.
     $js_groups = $this->grouper->group($js_assets);
 
@@ -109,33 +124,19 @@ public function optimize(array $js_assets, array $libraries) {

@@ -109,33 +124,19 @@ public function optimize(array $js_assets, array $libraries) {
           break;
       }
     }
-    if ($libraries) {
-      // Generate a URL for the group, but do not process it inline, this is
-      // done by \Drupal\system\controller\JsAssetController.
-      $ajax_page_state = $this->requestStack->getCurrentRequest()
-        ->get('ajax_page_state');
-      $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : [];
-      $language = $this->languageManager->getCurrentLanguage()->getId();
-      $query_args = [
-        'language' => $language,
-        'theme' => $this->themeManager->getActiveTheme()->getName(),
-        'include' => implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries)),
-      ];
-      if ($already_loaded) {
-        $query_args['exclude'] = implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded));
-      }
-      foreach ($js_assets as $order => $js_asset) {
-        if (!empty($js_asset['preprocessed'])) {
-          $query = [
-            'scope' => $js_asset['scope'] === 'header' ? 'header' : 'footer',
-            'delta' => "$order",
-          ] + $query_args;
-          $filename = 'js_' . $this->generateHash($js_asset) . '.js';
-          $uri = 'public://js/' . $filename;
-          $js_assets[$order]['data'] = $this->fileUrlGenerator->generateAbsoluteString($uri) . '?' . UrlHelper::buildQuery($query);
-        }
-        unset($js_assets[$order]['items']);
+    // Generate a URL for the group, but do not process it inline, this is
+    // done by \Drupal\system\controller\JsAssetController.
+    foreach ($js_assets as $order => $js_asset) {
+      if (!empty($js_asset['preprocessed'])) {
+        $query = [
+          'scope' => $js_asset['scope'] === 'header' ? 'header' : 'footer',
+          'delta' => "$order",
+        ] + $query_args;
+        $filename = 'js_' . $this->generateHash($js_asset) . '.js';
+        $uri = 'public://js/' . $filename;
+        $js_assets[$order]['data'] = $this->fileUrlGenerator->generateAbsoluteString($uri) . '?' . UrlHelper::buildQuery($query);
       }
+      unset($js_assets[$order]['items']);
     }

I don't think we need to move the code blocks around. I think we can add UrlHelper::compressQueryParameter() where it is needed and not move all the code and change the flow. Just in case someone where is relying on the if ($libraries) { behaviour ... and trying to keep changes to minimal necessary to implement.

catch’s picture

Status: Needs work » Needs review
FileSize
5.09 KB
14.94 KB

That's a good point. Kept the new additional comment since it's more important now we have to base64 encode and compress the string, but moved things back where they were otherwise.

catch’s picture

More can be inside the if ($libraries)

Status: Needs review » Needs work

The last submitted patch, 38: 3303067-39.patch, failed testing. View results

catch’s picture

Status: Needs work » Needs review
olli’s picture

  1. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,49 @@ public static function buildQuery(array $query, $parent = '') {
    +    return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(gzcompress($data)));
    

    The double encoding is gone now.

    <script src="/sites/default/files/js/js_gi8MaXv8i2qj2W0p7yMIoHc3LqEQ2PXt8dRkQm4-wPg.js?scope=footer&amp;delta=0&amp;language=en&amp;theme=olivero&amp;include=eJxtjEEOwyAMBD8UypsMdolVgyObgvh9pB7oJZc97MyuL-9UYwKnQ4UHmcYimkCC9yXcyq4vnWSEIa2QRPNngzcRHoNpevzlqyp-5f_XYHCBztqCU9aGYGtDJ7B8hsn4OLiM64PewEznDVS_Sdg"></script>
    
  2. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,49 @@ public static function buildQuery(array $query, $parent = '') {
    +   * Takes a compressed, urlencoded string and converts it back to the original.
    

    Drop "urlencoded" or replace it with "URL-safe" or "Takes a compressed query parameter and converts it back to the original."?

  3. +++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
    @@ -62,6 +62,49 @@ public static function buildQuery(array $query, $parent = '') {
    +   * @see \Drupal\Component\Utility\UrlHelper::compress()
    

    @see \Drupal\Component\Utility\UrlHelper::compressQueryParameter()

Other than that looks good to me.

catch’s picture

Should address #41.

b_sharpe’s picture

Status: Needs review » Reviewed & tested by the community

#42 looks good and addresses #41, working as expected for me and tests are passing. I don't see anything else here. RTBC

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 6d62cf2 and pushed to 10.1.x. Thanks!

The new code has test coverage and the URLs are smaller - nice.

  • alexpott committed 6d62cf23 on 10.1.x
    Issue #3303067 by catch, nod_, Wim Leers, olli, alexpott: Compress...
Wim Leers’s picture

YAY!

Hopefully see y'all in #3303067: Compress aggregate URL query strings next 🤓

voleger’s picture

Wim Leers’s picture

@voleger, oops, yes, that's what I meant 😅

Status: Fixed » Closed (fixed)

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