Problem/Motivation
JavaScript has become much more important. Every site on the web loads much, much more JS than even a few years ago.
At the same time, JS is maturing: everybody is using small JS libraries for various purposes. And then writing custom JS to use those libraries.
And of course, for all those small nice JS-powered things, we need CSS to style it.
Together, that's a lot of additional assets to load. But we have aggregation for that, so it doesn't harm us, yay!
Except that we also have drupalSettings.ajaxPageState
, to track which assets have already been loaded. This allows us to not load already-loaded CSS/JS again when loading additional content via AJAX, or using Drupal 8's Dialog system to open dialogs.
Consequently, drupalSettings.ajaxPageState
has grown. For a stock Drupal 8, when logged in, the front page has 4.3 KB of AJAX page state! And "rich" Drupal 7 sites like popsci.com or acquia.com have the same problem: 5 and 8 KB, respectively, and that's as the anonymous user! Sites like dev.twitter.com and whitehouse.gov simply remove drupalSettings.ajaxPageState
, presumably because it's slowing them down.
This means that in the HTML <head>
, we are loading several kilobytes of metadata. This means that it becomes increasingly likely that the critical content (what Drupal calls "main content") is not going to be sent first. Worse, since AJAX page state is put in the HTML before the HTML that loads JS files, it also means that we make it increasingly likely that a browser won't be able to start loading JS during the first received TCP packet.
You see, the first TCP packet can contain some ~14 KB of data (~16 KB on my localhost). If 4, 5, 8, and likely even more kilobytes of that are spent on AJAX page state… then that means that the browser is unable to kick off early requests for assets that need to be loaded. And it also means that the main content (e.g. node 5) is not going to be in that first packet either, and hence the assets therein (e.g. images) will also only start loading later.
Finally, the reason all of this truly matters, because it's quite easy to dismiss it: it feels like a micro-optimization, right? That reason is: latency. A huge part of the world is only connected to the internet via mobile networking technologies like EDGE, 3G and 4G if they're lucky. In fact, most of us are using the internet via our mobile devices, very often via 3G. I think everybody has seen extremely slow page loads on those networks.
In other words: if we want Drupal to be perceived as fast, including on mobile devices, then we have no choice but to do this.
Please see https://docs.google.com/presentation/d/1IRHyU7_crIiCjl0Gvue0WY3eY_eYvFQv... for details about optimizing the first TCP packet.
Finally, this is also a hard blocker for the following important performance issues, that have long-term consequences for Drupal 8:
- #1014086: Stampedes and cold cache performance issues with css/js aggregation — many a high-profile Drupal 7 site has suffered from this: massive cache stampedes bring the server to its knees. This issue paves the way for generating CSS/JS aggregates the same way we generate Image Styles, because it removes the need to know dozens and dozens of file paths in favor of only the absolutely essential library names, which can easily be conveyed in a GET request.
- #956186: Allow AJAX to use GET requests — without this issue, this is simply impossible to do, yet AJAX GET requests are a widely recognized best practice, because it allows AJAX responses to be cached (on the client side, in ISP proxies, in a reverse proxy). The sheer size of
drupalSettings.ajaxPageState
makes it impossible to have GET requests. - #1305882: drupal_html_id() considered harmful; remove ajax_html_ids to use GET (not POST) AJAX requests — real-world Drupal 7 sites are seeing significant slowdowns because of this, plus it's actually broken. See the summary of that issue for more details. This is a sister issue of this issue, and another required child for the preceding issue.
Proposed resolution
- A big part of the costliness of AJAX page state's current implementation is the fact that it lists Drupal-root-relative JS assets and basenames of CSS assets. That's… very wordy. But, now 99% of our attached assets are libraries… which means we can track which assets are loaded by using libraries exclusively. So I propose that we change
drupalSettings.ajaxPageState
from containing
{ theme: 'bartik': theme_token: '<token>', css: {…}, js: {…}, }
to:
{ theme: 'bartik': theme_token: '<token>', library: …, }
- Convert the remaining 1% to libraries also, and remove the ability to attach individual CSS and JS assets.
Remaining tasks
Get child issues committed:
- #2377397: Themes should use libraries, not individual stylesheets
- #2378095: Convert all remaining attached individual CSS/JS assets to attached asset libraries
- #2378263: hook_library_alter() must be manually invoked by users of LibraryDiscovery, and has no test coverage
- #2378789: Views output cache is broken
- #2382557: Change JS settings into a separate asset type
- #2382533: Attach assets only via the asset library system
At the same time:
- Implement a prototype.
- Build consensus/review
Then: commit.
Optionally, make it possible for JS to load additional asset libraries using the elegant/simple architecture used by Gmail: https://docs.google.com/presentation/d/1Q44eWLI2qvZnmCF5oD2jCw-FFql9dYg3...
User interface changes
None.
API changes
Removed already-deprecated APIs, the removal of which we've been working towards for several years, hence strictly speaking these are not API changes:
_drupal_add_css()
_drupal_add_js()
_drupal_add_library()
Actual API changes (which can be summarized as: introduce unit-tested, well-defined ways of resolving library dependencies and resolving libraries into concrete assets):
- Removed:
drupal_sort_css_js()
— this was only used internally, and because it was legacy procedural code, strictly speaking, anybody could be calling this. In practice, this is extremely unlikely - Removed:
drupal_get_css()
— less than 1% of contrib modules use this, if even 0.5% — it's only necessary if they want to output the HTML to load CSS manually for a very special use case - Removed:
drupal_get_js()
— same as above, but for JS - Changed:
drupal_process_attached()
— instead of processing/storing the attached assets in a global (which is precisely what we've been working to get rid of for several years), this now only handles non-asset attachments. - Added:
AttachedAssets(Interface)
: a value object for tracking the attached assets for a response, as well as the libraries the client has already loaded, if any. This is a value object thatAssetResolver
's methods accept, and it's howAssetResolver
can remain stateless. - Added:
AssetResolver(Interface)
: given anAttachedAssets
value object, this stateless service can determine which concrete CSS and JS assets should be loaded. Instead of myriads of strange edge cases being encapsulated in_drupal_add_(css|js)()
anddrupal_get_(css|js)()
, this now makes it abundantly clear what is happening. But, not to worry, 99% of developers won't ever to have interact with it; Drupal automatically calls it with the attached assets when rendering responses.
The methodsAssetResolver::getCssAssets()
andAssetResolver::getJsAssets()
now contain the logic of_drupal_add_css()
+drupal_get_css()
and_drupal_add_js()
+drupal_get_js()
, respectively.
This new class/service reuses the test coverage inAttachedAssetsTest
. - Added:
LibraryDependencyResolver(Interface)
: given a list of libraries, can expand that list of libraries with their dependencies, but can also calculate the minimal representative subset. This logic used to be implemented in a very convoluted way in_drupal_add_library()
, relying on statics to work.
The minimal representative subset is what is crucial in sending as little information as possible to the client-side: e.g. if you attach thecore/drupal.autocomplete
,core/jquery
andcore/jquery.ui.position
libraries, then we're going to load those two libraries plus all of their dependencies. But the dependencies ofcore/drupal.autocomplete
arecore/jquery
,core/drupal
,core/drupal.ajax
,core/jquery.ui.autocomplete
, and the dependencies ofcore/jquery.ui.autocomplete
arecore/jquery.ui
,core/jquery.ui.widget
,core/jquery.ui.position
andcore/jquery.ui.menu
. We don't want to list all of those dependencies: that'd be both space consuming and unnecessary: we can just listcore/drupal.autocomplete
and send that to the server with each AJAX request, the server (Drupal) then knows that all dependencies have been loaded, it doesn't need us to send the full list. Also, the second library that we attached wascore/jquery
, but that's actually already a dependency ofcore/drupal.autocomplete
, so we don't need to list that either. And finally,core/jquery.ui.position
is *also* a dependency ofcore/drupal.autocomplete
, although an indirect one. Nevertheless, that means we don't need to list that either.
Therefore the minimal representative subset in this example iscore/drupal.autocomplete
. That's right: that single library is sufficient! In computer science/graph theory terms: each asset library has dependencies and can therefore be seen as a tree. Hence the given list of libraries represent a forest. This function returns all roots of trees that are not a subtree of another tree in the forest.
This new class comes with comprehensive unit test coverage. - Added:
AjaxResponse::setAttachments()
, to no longer rely on the statics in_drupal_add_(css|js)()
to make Ajax responses aware of CSS/JS assets attached in render arrays. - Removed:
\Drupal\Core\Render\Element\(Scripts|Styles)
: these were used bydrupal_get_(css|js)()
, respectively, for rendering the CSS/JS asset collection into HTML. They're now obsolete. Their documentation has been retained; it's been moved to(Css|Js)CollectionRenderer
. - Changed:
drupalSettings.ajaxPageState
is only added if it's actually necessary, i.e. if thecore/drupal.ajax
library is loaded. - Added:
hook_(css|js|js_settings)_alter()
receive an additional parameter now:\Drupal\Core\Asset\AttachedAssetsInterface $assets
. This provides extra context, and might be necessary in the decision in what to do in those alter hooks.system_js_settings_alter()
uses it to generate theajaxPageState
setting.
Comment | File | Size | Author |
---|---|---|---|
#82 | optimize_ajaxpagestate-2368797-82.patch | 185.42 KB | Wim Leers |
Comments
Comment #1
Wim LeersComment #2
Wim LeersComment #3
Wim LeersComment #4
catchThe removal of support for individual css/js is a fairly large API change, although we implicitly say everything should be a library in 8.x so it shouldn't be used that much yet.
I don't think that's something we can do after release, and this will have a large impact on mobile performance, so at least for now I'm bumping it to critical.
This also blocks #1014086: Stampedes and cold cache performance issues with css/js aggregation although that should be possible to change during any minor release once this is in place.
Comment #5
dawehnerI wonder whether we can track which css/js files are not part of libraries and just add those? This would a) allow us easily to identify
which css/js files should be converted to libraries and b) not break any BC.
Comment #6
mgiffordComment #7
dawehnerThis is just a quick idea I had on the airport.
Instead of throwing away support for css/js we should still add them,
in case someone did not used a library.
This allows us to keep BC support, but also make it easy to detect which files
needs to be updated.
Comment #8
Wim LeersTwo child issues are now reviewable already:
Comment #9
Wim LeersAnother child issue created that is reviewable already: #2378263: hook_library_alter() must be manually invoked by users of LibraryDiscovery, and has no test coverage.
Note that the two child issues mentioned in #8 are now green and effectively blocked on review.
Comment #10
Wim LeersAnother child issue: #2378789: Views output cache is broken. The first three are all green.
Now tracking child issues in the IS.
Comment #11
Wim LeersComment #12
davidhernandez#2377397: Themes should use libraries, not individual stylesheets has been committed.
Comment #13
Wim Leers3 of the 4 child issues have landed. Time to open some more :)
Opened:
Comment #14
nod_For the record there is #956186: Allow AJAX to use GET requests Related to this too.
Comment #15
Wim LeersOh, thank you!
Comment #16
Wim Leers#2378095: Convert all remaining attached individual CSS/JS assets to attached asset libraries landed.
Now only blocked on #2382557: Change JS settings into a separate asset type, where consensus was reached but I just need to refactor it based on the consensus, and on #2382533: Attach assets only via the asset library system, which itself is blocked on that one too.
Almost there! :)
Comment #17
Wim Leers#2382557: Change JS settings into a separate asset type was committed!
Comment #18
Anonymous (not verified) CreditAttribution: Anonymous commentednote that the TCP window size of 14kb should be considered against compressed content, not uncompressed.
if you're not sending zipped up content over a high latency, low bandwidth connecton, none of this matters.
just so we don't get carried away with 4kb - that is 4kb of something over 100, not 4kb of 14.
Comment #19
Wim LeersAll true.
Two buts:
Of course this isn't a silver bullet. But it should help, and if we don't do it now, we won't be able to do it later. Thanks for helping keep ourselves honest though :)
Comment #20
jcisio CreditAttribution: jcisio commentedIf we were to be honest, then:
- Recent web servers enabled page compression by default. And most of Drupal sites send HTML gzipped, because page cache compression is enabled by default in Drupal.
- 14 or 16 KB packet size does not really have a significance for high-latency networks, because server sends multiple TCP packets before it stops to receive the ACK (e.g. client only sends ACK every second packet).
- ajaxPageState is compressed rather well, because parts of URL repeats.
But, a list of full path to assets in HTML is really ugly, and it personally keeps reminding me of .NET pages with 90% of HTML is used for JS post back!
The issue itself won't help much, but its children issues are great: a lot of DX improvement. It's like mathematics: to solve a purely theoretical problem, many improvements in other topics are made. Thanks Wim for the series.
Comment #21
mikeytown2 CreditAttribution: mikeytown2 commentedOptimizing the first KB is important thus putting the ajaxPageState in the footer seems like the thing to do. Won't #784626: Default all JS to the footer, allow asset libraries to force their JS to the header do this for us?
Comment #22
catchI just checked a Drupal 7 site - copy and pasted ajaxPageState to a file, then gzipped the file.
Uncompressed:
Compressed:
So even compressed that's over 10% of the 14kb.
Comment #23
catch@mikeytown2 if we can put drupalSettings in the footer that'd be great, but reducing its size seems worth doing independently of that - even if we default it to the footer, there'll still be sites where it ends up at the top.
Comment #24
Wim Leers#2378095: Convert all remaining attached individual CSS/JS assets to attached asset libraries just landed, so now this issue is fully unblocked! :) Working on this patch now…
Comment #25
catchComment #26
catchWould be good to document all the issues blocked on this one (imagecache asset generation, GET for AJAX requests) in the issue summary bette.
Comment #27
chx CreditAttribution: chx commentedhttps://www.npmjs.com/package/crapify this might help testing this.
Comment #28
Wim Leers#26: done! Also tried to improve the title.
Comment #29
Wim LeersFinally, a patch. Diffstat:
Updated the IS with the API changes.
(Note: the IS currently says Drupal 8 sends 4.3 KB of AJAX page state; that's now gone up to 4.9 KB…
"js":{"core\/assets\/vendor\/html5shiv\/html5.js":1,"core\/assets\/vendor\/jquery\/jquery.min.js":1,"core\/assets\/vendor\/domready\/ready.min.js":1,"core\/misc\/drupal.js":1,"core\/assets\/vendor\/underscore\/underscore-min.js":1,"core\/assets\/vendor\/backbone\/backbone-min.js":1,"core\/assets\/vendor\/modernizr\/modernizr.min.js":1,"core\/assets\/vendor\/jquery-once\/jquery.once.js":1,"core\/modules\/contextual\/js\/contextual.js":1,"core\/modules\/contextual\/js\/models\/StateModel.js":1,"core\/modules\/contextual\/js\/views\/AuralView.js":1,"core\/modules\/contextual\/js\/views\/KeyboardView.js":1,"core\/modules\/contextual\/js\/views\/RegionView.js":1,"core\/modules\/contextual\/js\/views\/VisualView.js":1,"core\/assets\/vendor\/jquery-form\/jquery.form.js":1,"core\/assets\/vendor\/jquery.ui\/ui\/jquery.ui.position.js":1,"core\/misc\/debounce.js":1,"core\/misc\/displace.js":1,"core\/assets\/vendor\/jquery.cookie\/jquery.cookie.min.js":1,"core\/misc\/form.js":1,"core\/misc\/progress.js":1,"core\/misc\/ajax.js":1,"core\/assets\/vendor\/jquery.ui\/ui\/jquery.ui.core.js":1,"core\/assets\/vendor\/jquery.ui\/ui\/jquery.ui.widget.js":1,"core\/assets\/vendor\/jquery.ui\/ui\/jquery.ui.button.js":1,"core\/assets\/vendor\/jquery.ui\/ui\/jquery.ui.mouse.js":1,"core\/assets\/vendor\/jquery.ui\/ui\/jquery.ui.draggable.js":1,"core\/assets\/vendor\/jquery.ui\/ui\/jquery.ui.resizable.js":1,"core\/assets\/vendor\/jquery.ui\/ui\/jquery.ui.dialog.js":1,"core\/misc\/dialog\/dialog.js":1,"core\/misc\/dialog\/dialog.position.js":1,"core\/misc\/dialog\/dialog.jquery-ui.js":1,"core\/modules\/quickedit\/js\/quickedit.js":1,"core\/modules\/quickedit\/js\/util.js":1,"core\/modules\/quickedit\/js\/models\/BaseModel.js":1,"core\/modules\/quickedit\/js\/models\/AppModel.js":1,"core\/modules\/quickedit\/js\/models\/EntityModel.js":1,"core\/modules\/quickedit\/js\/models\/FieldModel.js":1,"core\/modules\/quickedit\/js\/models\/EditorModel.js":1,"core\/modules\/quickedit\/js\/views\/AppView.js":1,"core\/modules\/quickedit\/js\/views\/FieldDecorationView.js":1,"core\/modules\/quickedit\/js\/views\/EntityDecorationView.js":1,"core\/modules\/quickedit\/js\/views\/EntityToolbarView.js":1,"core\/modules\/quickedit\/js\/views\/ContextualLinkView.js":1,"core\/modules\/quickedit\/js\/views\/FieldToolbarView.js":1,"core\/modules\/quickedit\/js\/views\/EditorView.js":1,"core\/modules\/quickedit\/js\/theme.js":1,"core\/assets\/vendor\/classList\/classList.min.js":1,"core\/misc\/active-link.js":1,"core\/modules\/views\/js\/views-contextual.js":1,"core\/misc\/announce.js":1,"core\/assets\/vendor\/matchMedia\/matchMedia.min.js":1,"core\/assets\/vendor\/matchMedia\/matchMedia.addListener.min.js":1,"core\/modules\/toolbar\/js\/toolbar.menu.js":1,"core\/modules\/toolbar\/js\/toolbar.js":1,"core\/modules\/toolbar\/js\/models\/MenuModel.js":1,"core\/modules\/toolbar\/js\/models\/ToolbarModel.js":1,"core\/modules\/toolbar\/js\/views\/BodyVisualView.js":1,"core\/modules\/toolbar\/js\/views\/MenuVisualView.js":1,"core\/modules\/toolbar\/js\/views\/ToolbarAuralView.js":1,"core\/modules\/toolbar\/js\/views\/ToolbarVisualView.js":1,"core\/assets\/vendor\/jquery-joyride\/jquery.joyride-2.0.3.js":1,"core\/modules\/tour\/js\/tour.js":1,"core\/misc\/tabbingmanager.js":1,"core\/modules\/contextual\/js\/contextual.toolbar.js":1,"core\/modules\/contextual\/js\/toolbar\/models\/StateModel.js":1,"core\/modules\/contextual\/js\/toolbar\/views\/AuralView.js":1,"core\/modules\/contextual\/js\/toolbar\/views\/VisualView.js":1,"core\/modules\/toolbar\/js\/escapeAdmin.js":1},"css":{"contextual.module.css":1,"contextual.theme.css":1,"contextual.icons.css":1,"jquery.ui.core.css":1,"jquery.ui.theme.css":1,"jquery.ui.button.css":1,"jquery.ui.resizable.css":1,"jquery.ui.dialog.css":1,"dialog.theme.css":1,"quickedit.module.css":1,"quickedit.theme.css":1,"quickedit.icons.css":1,"quickedit.css":1,"normalize.css":1,"system.module.css":1,"system.theme.css":1,"layout.css":1,"elements.css":1,"typography.css":1,"admin.css":1,"book.css":1,"breadcrumb.css":1,"captions.css":1,"comments.css":1,"content.css":1,"contextual.css":1,"dropbutton.component.css":1,"featured.css":1,"footer.css":1,"form.css":1,"forum.css":1,"header.css":1,"help.css":1,"highlighted.css":1,"list.css":1,"messages.css":1,"node-preview.css":1,"pager.css":1,"primary-menu.css":1,"search.css":1,"search-results.css":1,"secondary-menu.css":1,"shortcut.css":1,"skip-link.css":1,"sidebar.css":1,"table.css":1,"tabs.css":1,"tips.css":1,"triptych.css":1,"user.css":1,"vertical-tabs.component.css":1,"views.css":1,"buttons.css":1,"media.css":1,"colors.css":1,"print.css":1,"views.module.css":1,"toolbar.menu.css":1,"toolbar.module.css":1,"toolbar.theme.css":1,"toolbar.icons.css":1,"user.icons.css":1,"tour.module.css":1,"contextual.toolbar.css":1,"shortcut.module.css":1,"shortcut.theme.css":1,"shortcut.icons.css":1}
"libraries":"bartik\/global-styling,classy\/base,contextual\/drupal.contextual-links,contextual\/drupal.contextual-toolbar,core\/drupal.active-link,core\/html5shiv,quickedit\/quickedit,shortcut\/drupal.shortcut,system\/base,toolbar\/toolbar,toolbar\/toolbar.escapeAdmin,tour\/tour,user\/drupal.user.icons,views\/views.contextual-links,views\/views.module"
That's 4.5 KB less data to send on a stock Standard profile install on the front page. The new asset AJAX page state data is 356/4876=7.3% of the original, a 92.7% improvement. The total AJAX page state (including theme metadata) is then going to be 4971-4876+356=451 bytes, so 451/4971=9.1% of the original.
And this is on stock Drupal; the difference is going to be even starker on real sites!
This also happens to be a great way to spot problems: libraries that are unexpectedly listed there either:
views/views.module
, only contains table and grid style styling, neither of which is used for the frontpage view) and hence expose front-end performance problemsclassy/base
is implicitly a dependency ofbartik/global-styling
)I.e. a net DX improvement.
Comment #30
Wim LeersFor those of you wondering why I'm not listing every library: that's done for a good reason. See the "minimal representative subset" code, also explained in the IS:
Comment #32
dawehnerJust a relative quick review.
Meh, we have not marked this method as deprecated :(
Given that we no longer handle library and drupalSettings we have to update the documentation on that method itself. It talks about 'library' ...
Let's please explain WHY we want to do that.
It is a bit sad that this logic lives in a preprocess function.
Here is a question: Do we need a service with state here, or would it be enough to have a service which you can ask for a resolver with state? I don't have an overview of the patch yet, but at least here, there is just a single place where this is used.
Its odd, given that this might not be true for implementation using mechanisms like turbolinks.
Do we maybe want to have a method which makes simply an #attached array?
What happened with the existing alter hook?
Can we please describe what this is doing?
Oh wait, we should just implement the existing interface ...
Do we maybe want to append the existing ones?
Do we have a use for this method? I cannot find one in the patch. Note: If this is just a temporary object (see other comment) we would not need a reset() anyway.
Can we inject the dependency, or at least add a todo and a follow up?
<3
Can't you just do that with
$elements += ['#attached'];
Comment #33
Wim LeersThis doesn't address #32 yet.
A few of the failures were oversights on my part. But most of them were due to an API change that I hadn't yet mentioned in the IS: as of this patch,
ajaxPageState
is only added if it's actually necessary, i.e. if thecore/drupal.ajax
library is loaded. This makes AJAX page state compliant with the overarching rule: we only attachdrupalSettings
if an attached library depends on it.Most of the test failures in #29 occurred because they relied on
ajaxPageState
being present always, and absent in certain test case scenarios inDialogTest
,ViewAjaxTest
andRowUITest
that are very synthetic. They were extremely painful to fix.A few of the test failures were due to areas I'd overlooked: the
OpenDialogCommand
still attached thecore/drupal.dialog.ajax
library viadrupal_process_attached()
, to put it in the_drupal_add_js()
static, but that of course doesn't work anymore.We should really untangle the mess that is the
ajax_test
test module, because the incredible hacks upon hacks that we've gathered there over time make it an absolute hell to fix any test failures in that area. Not doing that here, because the patch is already big enough.A few problems that were spotted in the current patch, fixed in this reroll:
Problem introduced during rebasing. Will remove that change.
These switch statement cases need to be removed; they only exist because of a faulty rebase from long ago.
Comment #34
Wim Leersdrupal_get_js()
, then we have to keep the kludgy+fragile system we've been trying to get rid of around to some degree just for the very few people — if any — that are already using it in 8. This is a <1% API function.template_preprocess_html()
function is sad; we should move that logic into aHtmlResponse
class, or at least letHtmlRenderer::renderResponse()
take care of this. But doing *that* is out of scope here.Second: these checks used to be in the
#pre_render
callbacks of\Drupal\Core\Render\Element\(Styles|Scripts)
. But#type => '(styles|scripts)'
no longer makes sense to have, since A) all it did was return the output of a service, B) it was only used insidedrupal_get_(css|js)()
.Did that answer it?
I think the only thing preventing us from going with a stateless service and then sending the service the necessary state, is the advanced use case in
system_js_settings_alter()
:There are two reasons for it to live there:
core/drupalSettings
library'sdrupalSettings
, to replace it with the necessary dynamic values.AssetResolver
itself — which is the only possible alternative if we don't want to expose the state ofAssetResolver
— is at least equally ugly, because it's the content of a specific asset, and theAssetResolver
should only deal with the libraries-to-concrete-assets logic, not also generate the content of a specific asset.Yes, this means that we still have a global state. But only for the very briefest of moments: when
#type => html
is being rendered, until the asset alter hooks have been fired.That being said, I think the ideal would indeed be to make
AssetResolver
stateless. I think the ideal would look like this: add aResponseWithAssets
class that subclasses Symfony'sResponse
, which must be subclassed by any response that wants to attach assets (HtmlResponse
,AjaxResponse
…). It'd allow libraries and settings to be set on the response object. We could then have aKernelEvents::RESPONSE
event subscriber with a low priority, that'd run before the response is actually rendered, to map asset libraries to concrete assets. It'd call the statelessAssetResolver
, which would acceptResponseWithAssets
objects as input. And finally, with only a single API addition — also providing the Response object tohook_(css|js|js_settings)_alter()
, AJAX page state could just use the data on there, callAssetResolver
with it there and … voila!Thoughts? (It's late here so I'm probably missing something.)
Can you explain that? AFAICT Turbolinks would use AJAX page state also, or something like it. If you're on page X which contains libraries A and B, and you're going to page Y using Turbolinks, and that should contain libraries B and C, then you're going to want to only load library C.(Unless you're talking about "unloading" libraries… which shouldn't be necessary thanks to our
Drupal.behaviors
abstraction.)EDIT: oh, wait, I see what you mean. You're saying that in case of Turbolinks, the set of already loaded libraries would not be the empty set. Easy fix: check if
ajax_page_state
is available, and if so, use it. Fixed.If so, then I think: no. I think it's better to be explicit here. Render arrays are a vaguely defined, almost freeform data structure.
AssetResolver(Interface)
andLibraryDependencyResolver(Interface)
work with well-defined data structures. I think it's better to provide exact input, to not pollute those interfaces/classes with vague methods just to simplify a handful of calls.AjaxResponse
was doing VERY VERY … unethical things in order to have_drupal_add_(css|js)()
contain only those assets that hadn't been already loaded. Basically,AjaxResponse
implemented part of the logic ofdrupal_get_(css|js)()
to modify their internal state beforehand, and then it called them with the$skip_alter
parameter set toTRUE
. If that's not mind-bending, then what is? :P And now it's GONE!AssetResolver
is only called once per request. So unless I'm misunderstanding you, I don't think that makes sense.AssetResolver
multiple times, e.g. in asset library-analyzing code, I added it. If you want to remove it, I could live with that, but it seems prudent to be able to reset state.Comment #35
dawehnerSo I wonder whether we could pass along the object in the alter hook. It is certainly part of that domain already.
That kind of logic is really something you would eventually like to override.
I was thinkging about something like this:
but I can totally understand your point.
It just feels like bad design if you have to do that ... note the idea of passing around the storage would solve that at the same time.
Comment #36
mikeytown2 CreditAttribution: mikeytown2 commentedIn reply to #29, in terms of json output, can we do pretty print if in "development mode"; JSON_PRETTY_PRINT. Checkout advagg_json_encode() to see it in action. Thinking this will further help in identifying libraries that are unexpectedly listed.
Comment #37
Wim Leers#35:
That's a good point. That actually means we can separate state already; the outline of "the ideal" I provided is then not a blocker. Thanks! :)
Agreed, but that'd require that "ideal".
Agreed!
Trying this right now…
#36: Thanks!
Comment #38
Wim LeersImplemented #35.
What do you think, dawehner? I think I like it better, but I'm not fond of the name:
AttachedAssets(Interface)
seems like it could be better named.Comment #40
Wim LeersAnd once we have that, I think this is another logical incremental improvement.
Comment #42
Wim LeersRemoving debug leftovers…
Comment #43
Wim LeersPHP 5.4 sigh.
Comment #46
Wim LeersComment #47
nod_Tested, modals, views ui, quickedit, all working fine.
I don't have much to say on the PHP side of things, I love the clean-up of common.inc and removing calls to drupal_process_attached all over the place.
+1 for me
Comment #48
joelpittetI'm a big fan of the naming of the AttachedAssets (finally using Asset in the name!). I'll see if I can't try to do a deeper review of this over the weekend, but looks very nice at first glance.
Comment #49
dawehnerIt would be still nice WHY we are doing things here ...
missing inheritdoc
first question: its odd that we fetch all of the tree on every call and not store that information as part of the library discovery somehow ?
second question: why do we do the filtering in the recursive call for yourself and not rely on a
array_intersect()
right from the beginning. ... so instead of asking for the dependencies of $definition['dependencies'] we filter out the dependencies which we already resolved as part of $final_libraries.Next question: We retrieved all the dependencies, but why do we need all of them. Isn't the intersection with
$libraries
enough for us, given that this is the set we try to cover?Mh, I'm curious whether we could improve this by starting with a more greedy approach, like http://www.cs.ucr.edu/~neal/Papers/Young08SetCover.pdf
Comment #50
mondrakeThis method just replaces the attachments with a new value. Thinking also about #2347469: Rendering forms in AjaxResponses does not attach assets automatically, we may have several Ajax commands in a response, each targeting a different portion of the form, and each having its own attached assets. So, wouldn't it make sense to have a method that incrementally merges the input instead?
Comment #51
Wim LeersStraight reroll of #46. #2303761: Move views_ajax_form_wrapper() to ViewsFormBase broke this. Next: addressing the feedback.
Comment #52
Wim Leers#47: :)
#48: :)
#49:
Second Q: good question. I kept the same logic as
_drupal_add_library()
had. See more below, as part of the next point.getLibrariesWithDependencies()
returns a list, not a set! From the docs:This is used for determining which libraries to actually load, and loading libraries means loading assets, and that's sensitive to order, hence: list, not set.
When determining which things to load, we must therefore always use lists (since order matters), but when determining which things are already loaded, sets suffice (since order doesn't matter; we only need to know if a given library was loaded or not). Hence
getMinimalRepresentativeSubset()
's docs say this:First: the set-cover problem is NP-complete: https://en.wikipedia.org/wiki/Set_cover_problem.
Second: that same Wikipedia page also mentions the greedy algorithm as being the best choice.
Third: I'd argue that we can improve the algorithm performance later, if it turns out to be a problem. I think we should go with the easiest to understand code.
The current algorithm looks like this:
The greedy algorithm (
) would look like this:Not only does it look more complex, it's also more difficult to grok, because it's no longer directly using properties of the concepts we're dealing with: asset libraries. Asset libraries are fundamentally trees, not sets. By looking at it from a trees angle, as the current algorithm does (and as the documentation indicates), it keeps the algorithm simpler, IMO.
#50: Great remark! I'll be very honest: that's what I originally wanted to do. But that means
AjaxResponse
must usedrupal_merge_attached()
. And since that is still a procedural function, it has a big ripple effect: many AJAX unit tests will have to start mocking that. Since this patch already is very big, I wanted to leave that for that issue.Furthermore, and possibly an even stronger argument: I have not ever seen a single AJAX response with multiple HTML responses. So the actual value of that would be rather low. And in those rare cases, the logic building the AJAX response could just as well do the necessary merging, by calling
drupal_merge_attached()
itself? :)In any case, I'd like to get this in and then consider adding that.
Comment #53
Wim LeersThe weird whitespace in #52 is completely the fault of d.o's filter setup being broken, see #2396165: Code filter configuration removes blank lines.
Comment #54
dawehner@Wim Leers
So we need to file a couple of follow ups first?
Comment #55
Jelle_SPossibly related, or maybe a follow-up:
#1573020: Use data-* for CSS and JS files list
That issue focusses on moving the js & css files in
ajaxPageState
to data-attributes. Once this issue is committed we might want to consider movingdrupalSettings.ajaxPageState.libraries
to data-attributes?Comment #56
Wim Leers#54: I could file issues for looking into algorithmic optimizations and getting rid of
theme_preprocess_html()
. Did you want to see other issues filed too, or?#55: yep, sure, that's possible :) The storage mechanism doesn't matter for this issue; what matters is which actual data needs to be sent.
Comment #57
dawehner@Wim Leers
#50 also sounds like a candidate. Thank you, and sorry for annoying for todos.
Comment #58
nod_@Jelle_S that issue was because I wanted to do the diff of JS/CSS files to add on the frontend so we could remove some crazy stuff on the PHP side and not send a huge ajaxPageState. This issue makes the size very reasonable so there is no need to change how we do things. The patch makes it like it should have always been (patch which is RTBC for me since I don't have anything to complain about on PHP side).
I'm closing the other issue, thanks for bringing it up.
Comment #59
Wim LeersCreated the requested follow-ups:
Comment #60
YesCT CreditAttribution: YesCT commentedchanging to use the more common tag, so the less common one can be deleted, so it does not show up in the auto complete and confuse people.
Comment #61
Wim LeersChasing HEAD, #2403269: Update to jQuery UI 1.11.2 broke this.
Comment #62
Wim LeersNote the diffstat:
58 files changed, 1494 insertions(+), 1299 deletions(-)
— 711 lines are removed fromcommon.inc
, bringing that down from 2709 LOC to 1998.(All the while bringing full unit test coverage for resolving library dependencies, which has zero explicit test coverage in HEAD.)
Comment #63
dawehner... this is quite impressive, especially because we add new functionality. Nevertheless, I am not able to remotetly judge all the implications these changes have.
Comment #64
Wim LeersSo nod_ says he can't sign off on the PHP. dawehner says the same.
Pinged Moshe for a review.
In the mean time, all I can do to push this issue forward, is to review it myself for nitpicks and fix those. And I found a whole bunch :) Now the diffstat has gotten even better:
58 files changed, 1471 insertions(+), 1299 deletions(-)
.Comment #65
Fabianx CreditAttribution: Fabianx commentednit - I would love (a followup) helper function for that.
Given this will be cleaned up anyway and removed from preprocess_html ...
nit - Maybe also check for #printed?
Follow-up, but this should have the renderer injected or not?
Here is the todo:
Change Record:
1. setAttachments for responses (very important for Module Devs)
2. Changes to hooks like hook_js*_alter
IS:
1. hook_js*_alter API changes
----
My biggest concern is the DX around explicitly setting assets, but I feel that can be follow-up.
While explicit is good, there is more to that and I probably would have preferred some helper function to take a render array and turn it into a response (where assets and other meta data like cache tags are added / can be added directly).
That would give contrib the most flexibility, while at the moment it trains programmers to just add assets, which will make things difficult to extend for the future.
e.g.
$response = Response::fromRenderArray($build);
similar to how getCacheableRenderArray works right now.
It could still use ->setAssets internally, so explicitness is still there and no changes needed, but it also allows contrib to take other render array properties into account without having to exchange all services and potentially all contrib services, too ...
----
Besides that from my POV this is **RTBC**.
I am leaving it some days for global sprint weekend for others to review as its rather huge - unless Wim wants core committers to take a look already now ...
Comment #66
Wim LeersUpdated the IS. Next: creatinga CR.
Comment #67
Wim LeersCR created; minor changes in IS, to improve clarity.
Comment #68
moshe weitzman CreditAttribution: moshe weitzman commented#47 did manual testing of this change, so I did yet another code review. The code is clear, and much improved. Test coverage is much improved. I join FabianX (#48) in calling this RTBC. Looking forward to some Performance issues being unblocked when this goes in.
Comment #69
moshe weitzman CreditAttribution: moshe weitzman commentedComment #70
webchickFantastic!
Since this is performance-related, tossing over to catch.
Comment #72
rteijeiro CreditAttribution: rteijeiro commentedSimple re-roll. Only conflicts in user.module file.
Comment #73
mondrakeMinor, I did not add
core/drupal.ajax
in the dependencies of my module libraries.yml file, and$ajax_page_state['libraries']
is undefined at that point, yielding to notices:Notice: Undefined index: libraries in Drupal\Core\Ajax\AjaxResponse->ajaxRender() (line 130 of /[...]/core/lib/Drupal/Core/Ajax/AjaxResponse.php).
Notice: Undefined offset: 1 in Drupal\Core\Asset\LibraryDependencyResolver->doGetDependencies() (line 58 of /[...]/core/lib/Drupal/Core/Asset/LibraryDependencyResolver.php).
Comment #74
rteijeiro CreditAttribution: rteijeiro commentedSorry @mondrake, could you tell me what module do you mean, please?
I'm trying to find the place to add the dependency but not sure where.
Comment #75
mondrake@rteijeiro sorry, of course, it's a contrib module, not core :)
I think the point here is simply that
AjaxResponse::ajaxRender()
is assuming that the$ajax_page_state
array retrieved from the request will always have a 'libraries' key, which is not necessarily true.Maybe just
would do?
BTW - adding
core/drupal.ajax
in my *contrib* module libraries.yml dependencies fixed the problem for meComment #76
Wim LeersStraight reroll of the RTBC'd #64, and hence putting back at RTBC.
#73/#75: AFAICT what you're describing is impossible. I think you're testing with an improperly patched site.
Either the
ajax_page_state
value is present in the HTTP POST data, or it's not. If it's present, then it contains a'libraries'
key. Your problem description says that theajax_page_state
value is present, but it doesn't contain a'libraries'
key. This is impossible.Therefore the existing check is sufficient. If it turns out I'm wrong, then we can very easily fix it in a follow-up issue.
Comment #77
mondrake#76 agree, it's something wrong with what I was doing in the contrib module. Sorry for derailing and thanks for feedback.
Comment #78
catchOverall this looks great. Could only really find minor comment nitpicks.
Doesn't this just get a list of needed libraries?
It's not 100% clear whether the dependencies of the needed libraries will also be excluded from the comment. Could this say something like "A list of libraries and their dependencies in the order they should be loaded, excluding any libraries that have already been loaded"?
Is this 'module' first or 'default' first? Would probably just use the constants here.
immplies
Comment #79
Wim Leers@return
doc was confusing, clarified.drupal_get_css()
: since I kept the same logic, I also kept the comments. But, per your request, updated it.Also made one tiny change in
theme.inc
, at chx' request: retrieve a service once, assign it to a variable, instead of retrieving the same service twice.Since this reroll only changed comments for all intents and purposes, leaving at RTBC.
Comment #80
Jelle_SQuick scan through the patch, found some more (really small) nitpicks to be added to those of #78
Ugh, sorry Wim, seems my comment is just a bit too late, I was hoping to finish the review before you addressed #78
Nit: Punctuation & grammar:
"[...] settings assets. and converts [...]" (either capital or comma)
"[...] get the Javascript [...] converts them [...]" (get vs gets or convert vs converts)
Nit: 81 chars (80 char comment limit)
Nit: 81 chars (80 char comment limit)
Nit: 81 chars (80 char comment limit)
Nit: 81 chars (80 char comment limit)
Nit: Punctuation.
Nit: 80 char comment limit
Comment #82
Wim Leers#80: np, thanks :) All addressed in this reroll! Still keeping at RTBC, because again only comment changes.
I cannot reproduce the failure in #79 locally, assuming it was a random fail for now.
Comment #83
catchCommitted/pushed to 8.0.x, thanks! Lot of work to get here.
Comment #85
Wim LeersYAY! Thanks for the commit! Next: those requested follow-ups, all of which will be non-critical :)
Comment #86
Wim Leers#36: actually,
JSON_UNESCAPED_SLASHES
is what we want :) But, turns out this option is not turned on for a good reason: http://stackoverflow.com/questions/1580647/json-why-are-forward-slashes-....JSON_PRETTY_PRINT
while developing would be nice though, but is completely unrelated to this issue. Feel free to file a feature request against 8.1, I'd like to have that too :)#59:
AjaxResponse::addAttachments()
at #2407201-3: Consider introducing AjaxResponse::addAttachments().#65:
#attached
is available or not. There's no need to check for printing.Patch that defaults JS to the footer: now available, and very simple, at #784626-148: Default all JS to the footer, allow asset libraries to force their JS to the header.
WHEW! That was all the follow-up stuff.
Comment #87
mondrakeI think this has caused #2412805: View preview does not attach assets provided by plugins.
Comment #88
ianthomas_ukIt looks like this has broken aggregation
Comment #90
xjm