Motivation
This issue is to investigate an alternative approach for finding big pipe replacements using a MutationObserver.
To improve the perceived loading speed of pages, the big pipe module on the server-side replaces the dynamic parts of html pages with placeholders while printing and flushing the output of their replacements just after the static part. During page load on the client-side those placeholders are being replaced with their replacements.
For the placholders <span data-big-pipe-placeholder-id="…"> elements are being used, for their replacements a <script type="application/vnd.drupal-ajax" data-big-pipe-replacement-for-placeholder-with-id> element per placeholder.
The replacement of the placeholders is done using Javascript. To find the replacements during page load a setTimeout() callback is being used with a short interval of 50ms (20 times per second). The callback starts looking for replacement script elements once a special <script type="application/vnd.drupal-ajax" data-big-pipe-event="stop"> element is printed and found. The interval is stopped once a <script type="application/vnd.drupal-ajax" data-big-pipe-event="stop"> element is printed and found or on the page load event.
This interval is not very efficient. For example the callback needs to account for newly found replacement elements not having fully arrived yet; parsing their content would fail.
Now we no longer have to support IE10 we can start using a MutationObserver to find the big pipe replacements.
As we no longer have to account for partial big pipe replacement elements we can also immediately remove these elements on processing. This allows us to remove the dependency upon jquery.once and jquery all together.
As a second step we can also remove big pipe's start and stop signals as they are no longer needed to start and stop the interval to look for replacement elements.
Remaining tasks
Test, refactor, decide.
API changes
Big pipe's replacement detection process will work totally different, but this can be considered internal.
Comment | File | Size | Author |
---|---|---|---|
#105 | setTimeout-101-placeholders.png | 140.89 KB | nod_ |
#105 | mutation-observer-101-placeholders.png | 135.19 KB | nod_ |
#103 | core-bigpipe-3196973-103.patch | 14.37 KB | nod_ |
| |||
#91 | core-bigpipe-3196973-91.patch | 18.69 KB | nod_ |
#89 | core-bigpipe-3196973-89.patch | 17.69 KB | nod_ |
Issue fork drupal-3196973
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
Comment #2
casey CreditAttribution: casey at SWIS commentedThis is just a POC.
Instead of using <script> elements with json encoded ajax commands, the big pipe replacements are output as <template> elements. A MutationObserver is used to replace the big pipe placeholders with the content of the s<template>s.
The Drupal ajax.js API dependency is removed. The jQuery dependency is only needed to deep merge drupalSettings.
Comment #3
casey CreditAttribution: casey at SWIS commentedWithout changed tests to ease reviewing, the previous patch will probably fail anyways.
Comment #4
nod_That looks interesting :)
Developpement happens on the latest branch which is 9.2.x these days, there shouldn't be any new features on the 8.x branch.
Comment #5
casey CreditAttribution: casey at SWIS commentedComment #6
droplet CreditAttribution: droplet commentedIs it correct changes?
Comment #7
casey CreditAttribution: casey at SWIS commentedYes, this new approach uses a Mutation observer to find and process big pipe replacements. The observer is started immediately and stopped/disconnected on DomContentLoaded.
The start and stop signal elements are no longer necessary.
Comment #8
casey CreditAttribution: casey at SWIS commentedAn issue a found is that < template > is not supported by IE11. It can be polyfilled to some extent, it is however not possible to prevent scripts inside the templates from being executed.
Is it ok to make IE fallback to the noJs solution?
This would also allow us to use some even newer JS in the big pipe script.
Comment #9
droplet CreditAttribution: droplet commented** Disclaimer: I don't truly understand how big pipe works (PHP side mainly)**
#7,
From my understanding, the current approach uses `setTimeout` in bigPipeProcess to process the new elements, not the signal?
current bigPipe's `Drupal.ajax` doing a simple DOM job, not the networking. (plus potential problems: https://www.drupal.org/project/drupal/issues/1988968)
Is the SIGNAL a bug (or useless) all time?
#8,
I believe this patch's trick is MutationObserver, not the TEMPLATE tag. In bad cases, we can take the current response format (with mods).
Comment #10
droplet CreditAttribution: droplet commented@nod_,
Just interested why we don't use `MutationObserver` before and find you:
https://www.drupal.org/project/drupal/issues/2469431#comment-10471584
Comment #11
nod_5 years later mutation observer support is good on all supported browsers so we can go that way now :)
The script tags used to write the data from bigpipe is wrapped in a script type that has a special type. The code inside is considered a "data block" and not parsed by the browser so it's doing the same thing as a template.
Comment #12
casey CreditAttribution: casey at SWIS commentedThis patch only replaces the interval with a MutationObserver. We can still clean up the start/stop signals.
Comment #13
nod_Thanks, there are a bit too many changes here. not only changing what's triggering the processing happens but also how things are processed.
We still need the .once library
So because we use mutation observer we won't run into the issue outlined below in the function, but let's get the patch green before trying to change that one. Let's keep this function as-is for now and see if can improve it once the rest is ok.
I don't see the value of separating this function, can we put that directly in
processMutations
?We have a polyfill for that, add
core/drupal.nodelist.foreach
in the dependencies and you won't need this.Here and in the rest of the file why not use functions? If it's for the character count, it's not shorter:
a bit verbose, we can use
node.matches('script[data-big-pipe-replacement-for-placeholder-with-id]')
The cleanup happened at window load, any particular reason to change this?
And there are a bunch of code standards errors, make sure that
yarn run lint:js-core-passing
is clear.Comment #14
droplet CreditAttribution: droplet commentedthis patch reminded me (give me confidence) that my suggestion below could take one more step to remove the `setTimeout` (MutationObserver or any monitors). Just do a simple pair the execute the command with the responses
https://www.drupal.org/project/drupal/issues/2903614#comment-13717905
removed setTimeout:
https://git.drupalcode.org/issue/drupal-2903614/-/compare/9.2.x...290361...
** and @casey is right! Probably we can remove the start/stop SIGNAL
Comment #15
nod_Pretty neat, it makes it a bit harder for CSP #2513356: Add a default CSP and clickjacking defence and minimal API for CSP to core but not impossible. Not 100% sure which solution to go with.
Comment #16
casey CreditAttribution: casey as a volunteer and at SWIS commentedThanks @_nod for the review
Comment #17
casey CreditAttribution: casey as a volunteer and at SWIS commentedHmm tests are going to fail.. they assert their presence and content.
Not sure what to do. Remove/rewrite those tests, or revert to jquery.once?
Comment #18
casey CreditAttribution: casey as a volunteer and at SWIS commentedNot sure though, lets see what happens
Comment #19
casey CreditAttribution: casey as a volunteer and at SWIS commentedThis patch also removes the start/stop signals.
Comment #20
casey CreditAttribution: casey as a volunteer and at SWIS commentedComment #21
casey CreditAttribution: casey as a volunteer and at SWIS commentedWhile moving to node.matches missed that not all nodes are elements.
Comment #22
casey CreditAttribution: casey as a volunteer and at SWIS commentedComment #23
casey CreditAttribution: casey as a volunteer and at SWIS commentedSorry for the noise; haven't done any core development lately :)
Comment #26
casey CreditAttribution: casey as a volunteer and at SWIS commentedThis one should pass.
Why 3196973-23-removed-start-stop-signals.patch is failing is not clear to me; I think the test (added in #2678662: Ensure BigPipe does not break when HTML document contains CDATA sections or inline scripts matching certain patterns) was incorrect to begin with. It asserts the big pipe start signal is not injected inside a inline script containing a </body> tag. But this fail indicates the inline script isn't found either way.
Comment #27
casey CreditAttribution: casey as a volunteer and at SWIS commentedComment #28
casey CreditAttribution: casey as a volunteer and at SWIS commentedI don't understand why the latest patch is failing. @_nod could you help out?Comment #29
casey CreditAttribution: casey as a volunteer and at SWIS commentedComment #30
casey CreditAttribution: casey as a volunteer and at SWIS commentedComment #31
nod_#14 is nice :) I like the simplicity of it. On the other hand, as a maintainer inline JS makes CSP harder (not impossible, just harder) and it adds JS code inside PHP strings, which makes things harder to keep track of. As much as I like the solution, I would rather not add JS-in-PHP code in core.
Patch is looking better :)
Still worried about that one, I'm thinking async script could be an issue somehow.
then again core support for async is pretty poor.
I don't want to remove the start/stop signals yet, it might be a BC break, i'll check first.
Can you update the issue summary with the fact that we're removing the direct jquery dependency as well as using mutation observer? Thanks!
Comment #32
droplet CreditAttribution: droplet commented@nod_
We are always able to tweak it. I always prefer a fast workaround first if it won't bring extra BC noises.
(Technically, MutationObserver will resolve the race conditions problems. Not truly revolved but the probability will become verrrrrrrry low. I will perform some basic tests soon)
(https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/)
`node.nodeName === "SCRIPT"` can filiter out more unrelated before more expensive `matches`
Comment #33
casey CreditAttribution: casey as a volunteer and at SWIS commentedComment #34
casey CreditAttribution: casey as a volunteer and at SWIS commented@_nod, in response to your reservations on using DOMContentLoaded,
I don't think we should support big pipe replacements being added in any different way than as part of the http response.
I think this is a major (relatively, only in the context of big pipe replacement processing) performance improvement, especially when other scripts do mutate the body element.
Comment #35
casey CreditAttribution: casey as a volunteer and at SWIS commented@droplet, oh wow didn't know there is such a thing as microtasks... thanks for sharing.
I agree using MutationObserver will probably fix race conditions like #2903614: Race condition results in same CSS+JS being loaded twice: race between BigPipe's server-side dynamic asset loading and Quick Edit's client-side dynamic asset loading.
If it still is a problem we could, just before processing a replacement element, remove it from drupalSettings.bigPipePlaceholderIds.
Comment #36
casey CreditAttribution: casey as a volunteer and at SWIS commented@_nod I updated the issue summary. Or did you still want to revert to the load event?
Comment #37
droplet CreditAttribution: droplet commented`DOMContentLoaded` is better than `load`.
BUT, we may have to use CORE's domReady instead.
Comment #38
nod_we can remove the start/stop signals from the html, just need a change record.
Comment #40
voleger#3204273: Remove jQuery dependency from BigPipe is in
needs reroll
Comment #41
andypostComment #42
Wim Leers🤞 Would be cool to move this forward again indeed!
Comment #44
andypostre-roll of #30
Comment #45
droplet CreditAttribution: droplet commentedcould we do this instead?
this provided an easier way to swap it.
** I preferred it under `window.Drupal` than `drupalSettings` if we can.
Comment #46
andypost@droplet do you mean kinda that?
Comment #47
andypostfix cs, interdiff vs #44
Comment #48
droplet CreditAttribution: droplet commentedshould be
`Drupal.bigPipe.processMutationsCallback = Drupal.bigPipe.processMutationsCallback || function processMutationsCallback() {}`
Comment #49
andypostFixed
Comment #50
nod_@droplet in what situation would you override the domcontentloaded callback? I'm not sure what would be the advantage of allowing to swap those callbacks.
Comment #51
nod_Need to deprecate the setting:
@droplet, i would understand if what we can override is the
processMutations
orprocessReplacement
functions since it could change how we select elements to process and change the way the replacements are processed but I'm not sure about the load callback.I mean the load callback should probably be a behavior no?
Comment #52
Wim LeersNice — it'll be deprecated and have zero effect, because the interval basically becomes
0
, always, since it'll always be instantaneous thanks to this much better browser API! 🥳Loving this improvement!
Übernit: 80 cols:
progress
would now fit on the first line 🤓Same thing here.
🤩
Comment #53
yogeshmpawarAddressing nitpicks mentioned in #52
Comment #54
yogeshmpawarAddressed nitpicks mentioned in #52 & also added an interdiff between #49 & #54
Comment #55
volegertried the #54 patch on the 9.3.x setup and caught this error:
Uncaught TypeError: Cannot read properties of undefined (reading 'processMutationsCallback')
tested on chrome:94.0.4606.61
Comment #56
volegerThis line throw the error
Comment #57
volegerDrupal.bigPipe is undefined at that line, so it has to be initialized
Comment #58
andypost@voleger that's very strange to me because it works for me in FF and chrome but in console
Uncaught TypeError: Drupal.bigPipe is undefined
Comment #59
volegerI'm wondering why the test suite does not track the console for thrown js errors?
Comment #60
Wim LeersReminds me of #3228956: PHP notices are not detected for headless browser-triggered requests in FunctionalJavascript tests.
Comment #61
droplet CreditAttribution: droplet commentedYou're right. I overlooked how it works. Then it may not be worth making it optional. (because replace the whole file is better)
Comment #62
nod_Since we don't need to make the implementation switchable, Can we go back to the code of patch #44 with the fixes from #52 applied?
Thanks, almost there :)
Comment #63
uzlov CreditAttribution: uzlov at Skilld commentedobject Drupal.bigPipe is NOT existing at all!
should to be something like
Drupal.bigPipeProcessMutationsCallback = Drupal.bigPipeProcessMutationsCallback || ...
...
window.addEventListener('DOMContentLoaded', Drupal.bigPipeProcessMutationsCallback);
Comment #64
andypostI think it's ok to keep it replaceable, just no reason in extra object in Drupal object
Interdiff vs #54
Comment #65
nod_If we make things replacable it should be processMutations, not the processMutationsCallback function, it's not clear to me what the benefit would be to make this one pluggable, do you have a use case for this?
Still think we should go with #44.
Comment #66
andypostI can't find other case except de-touch of listener for having a name
patch as #62 suggests
btw it can win few more bytes in size via #65 - thank you!
Comment #67
nod_is it me or does this new code makes the CPU work extra hard compared to the previous implementation?
Comment #68
longwaveIs there a more efficient way of checking the node other than
.matches
? We know it must be a script tag with a specific attribute?Do we have to observe the entire body and all children, can we restrict this somehow? I guess one issue is that when we process the replacements, the mutation observer is being fired again because we are adding more nodes to the DOM.
Comment #69
longwaveCan we set
subtree: false
? We know where the nodes will be added, we don't have to observe the entire tree?edit: more suggestions here that might help: https://stackoverflow.com/a/39332340
Comment #70
nod_We should try #32
Comment #71
droplet CreditAttribution: droplet commented@nod_
Is the extra CPU time unacceptable? or just more. New implementation must use more CPU.
this line is unnecessary in new approach. Or we should update them to something (eg. remove attr) that won't trigger MutationObserver childList's update.
If we see bigpipe process as one whole thing, removing them after
observer.disconnect();
is better.Comment #72
nod_To me it's unacceptable at the moment. Very good point about not removing element right away
Comment #73
nod_All right, chaged a few things, looks like my cpu is happy now.
I didn't remove the scripts from the DOM because we didn't do that before either. not sure of the benefit of removing them.
Comment #74
nod_used that because the DOM is changing so it makes sense to do things before rendering. setTimeout or queueMicrotask works too.
Comment #75
droplet CreditAttribution: droplet commented@nod_
The changes make sense but I wonder about the limits. Could you drop a counter to see how many replacements are on your test env? I bet your theme has a complicated layout that triggers CSS reflow..etc. Or triggered
Drupal.attachBehaviors
?(https://git.drupalcode.org/project/drupal/-/blob/9.3.x/core/misc/ajax.es...)
And what's your CPU model (if you don't mind I asked)
We are able to take off
observer.disconnect();
and code a simple script to queue 10, 20, 30, 50 fake replacements per time and see what happens.and IE11 testing if it is still supported. (and slow Android phone?)
(The reason I asked optional callback before is I want to change the rendering timing for some cases)
Comment #76
nod_There are 4 replacements on most pages, and my CPU is getting old: Intel(R) Core(TM) i7-4600U CPU @ 2.10GHz. I'm using an empty drupal site with olivero and claro enabled. I'm mainly going through the admin UI like the content type field UI, menu UI, etc.
I think the nodename check, removing the matches and not removing the script element was the main reason for the improvement. calling directly processReplacement worked well too, it didn't have visible impact on performance. Using requestAnimationFrame was more of a "just in case" thing.
Comment #77
Wim Leers#67
Ehhh… 😬😨. This is why I wrote #52, but apparently that's not true?
in#73–#76: any chance that the DOM changes made by the removal of the
<script>
tags previously actually triggered more mutation events? In any case, I'm happy to hear that the performance regression is now gone.We should also test this on low-power devices. BigPipe went through a very long, very cautious release process into Drupal 8's standard install profile (see https://wimleers.com/blog/drupal-8.3-bigpipe-module-stable), to minimize disruption risk. We should be at least equally mindful now.
Comment #78
droplet CreditAttribution: droplet commentedThe change of
removeChild
reduced below replacement execution time from 4ms to 2ms. It's a lot.However, I can't figure out the base problem because all other 3 replacements have no difference.
And I noticed if I'm testing it in normal browser env with adblockers (, and other plugins), the execution time increased to 7ms. (I'm surprised.)
My guess is
MutationObserver
moving the execution timing toloading
orinteractive
. At that moment, browser is heavy loading. (near to FCP & LCP, peak CPU loading)Then, we have another further tweak:
With
MutationObserver
,Drupal.attachBehaviors
could be called before the standard one fromdrupal.js
We able to intrdocue a global flag
window.Drupal.isGlobalAttachBehaviorsFired
and skip them.(Needs
TRUE
by default if we allow exception for other scriptswindow.Drupal.allowFiredBeforeGlobalAttachBehaviors = true
)** same to
Drupal.detachBehaviors
** not-related issue: scripts from comment editor trigger 35 times of
Drupal.attachBehaviors
for non-Aggregate JavaScript files. 3 times for Aggregate enabled.**
** also, I noticed non-sense executions time on
$newContent.each
inDrupal.AjaxCommands.prototype.insert
. Needs try to replace $.each and profile again.Comment #79
Wim LeersI think that means this needs some work? :)
Comment #80
nod_I think we might need to keep the timing concept and only use the mutation observer to fill in a queue of things that needs to be processed. This would remove some uncertainties (no need for the try/catch around json.parse) while keeping the performance profile.
Or maybe do a debounce type of thing and start processing elements when things are idle for a few hundred miliseconds.
Comment #81
droplet CreditAttribution: droplet commentedIs it just a concept (theory)? You can't query the SCRIPT if there's no closing tag before
DOMContentLoaded
eventOtherwise, you can flush a START tag
<div id="bigpipe"><script type="application/vnd.drupal-ajax" data-big-pipe-event="start">
and reduce the scope to(I'm sure it doesn't work. I have tested it before)
Replace
setTimeout
torequestAnimationFrame
is another option. I don't think extra calls is a performance problem.It doesn't right. We need it as FAST as possible.
** Further tweaks can do later on a new issue. Current code on slow response has the same problem
Comment #82
andypostthe related for #81
Comment #85
finnsky CreditAttribution: finnsky at Skilld commentedTwice runned drupal environment without/with patch #73
- new article /node/1 with one comment. Caching enabled. Olivero with admin bar.
on chrome lighthouse:
before patch: https://gyazo.com/261803aef558db58a5a0ce3cb44e20ff
after: https://gyazo.com/e2d66c4097fc5bbcc31dcbfde9de937d
Largest Contentful Paint became much better after patch.
Cumulative Layout Shift became less dramatical.
Comment #86
andypostThe improvement is more then x2 interesting how it depends on platform+browser
But anyway impressive
Comment #87
Wim Leers#85: wow! Thank you! Bumping to
based on that!@nod_ I think we should try to finish this 🤓
I marked this
in #79 based on @droplet's review in #78. But in #81 droplet said → let's move this back to , and let's first hear back from @nod_ what he thinks should still be done 😊Comment #88
nod_Ok so the numbers are better with mutation observers indeed, we remove 2 layout shifts so that's kind of a big deal as far as web vitals are concerned.
What changed from #73 is that I'm reusing the same ajax object instead of creating a new one each time and I get rid of it at the end.
Removed the requestanimationframe, since that's not what it's for and it doesn't help too much with layout shifts and it delays the LCP a few ms compared to calling it directly.
I think this broke the cke5 admin ui though, will need to look into it.
Comment #89
nod_yeah cke5 admin is messed up because the context for the behavior is not what's expected by the code. CKE expects
document
but bigpipe givesul.toolbar-menu
. The once call is missing the context argument. Fix looks big but it's just space/indent changes. Check the compiled file it's pretty clear :)Comment #91
nod_Comment #93
andypostRelated to #88 about list of ajax objects
Comment #94
Wim Leers#89: nice bug find 🤠
Übernit: probably needs an empty line in between?
Why this reflowing into 80 chars? I thought the 80 char limit didn't apply to JS?
(Trying to reduce the patch to the smallest size possible.)
Could you please not reflow all that (out-of-scope) CKE5 admin JS code? That makes this diff much harder to read 🙈
Comment #95
nod_I let prettier do it's thing. If we don't reflow it'll fait commit checks
Comment #96
nod_probably needs reroll following #3294720: The attachBehaviors() for document is only called after Big Pipe chunks are processed
Comment #97
nod_reroll to 10.0.x branch, added update from #3294720: The attachBehaviors() for document is only called after Big Pipe chunks are processed.
#94.1 : fixed
#94.2 : I've always kept the 80 chars limit in js files too.
#94.3 : autoformat, can't do much about that. The diff to the built file is pretty clear about the changes that are introduced.
we'll need a different patch for 9.5 (need to add a polyfill at least)
Comment #98
Munavijayalakshmi CreditAttribution: Munavijayalakshmi at Valuebound for Valuebound commentedComment #99
longwave#85 is very promising, #94 was addressed, the patch otherwise looks good to me and I have no review comments -> RTBC.
Comment #100
quietone CreditAttribution: quietone at PreviousNext commentedSetting this to Unassigned since this is now RTBC.
Comment #102
Wim LeersRandom fail in
1) Drupal\Tests\book\Functional\BookTest::testGetTableOfContents
? Re-testing, re-RTBC'ing.Comment #103
nod_updated patch after js file renames
Comment #104
lauriiiI did some manual testing that was requested on #77 using an iPhone 5 which was the device with lowest CPU performance that I had access to and could debug reasonably. I didn't see any regressions in the experience but I think the phone might have had too high CPU performance to prove this isn't the case with any phone. I also couldn't get access to the CPU usage so this was all based on perceived performance and timelines.
I was able to confirm higher CPU usage, and higher peak CPU usage on my newer iPhone. On this device the change was very moderate but I think it's enough to justify some extra investigation with a lower performance device.
Comment #105
nod_All right, dusted my old hardware. One was way to old to even run devtools (it's on android 4.4) The oldest I could reasonably get to work was a galaxy A5 (2016), it has a pretty poor geekbench score (less than half of the iphone 5) so I guess it's a good stand-in for cheap current hardware, want's that expensive at the time either.
For the test I tweaked the BigPipeTestController::multiOccurrence controller to add 100 placeholders and an additional item that renders with a delay of 1 second. Removed toolbar/contextual links that were adding noises to measures.
Here are both traces. In short it doesn't change much. The bottle neck is in the DOM/jQuery/attachbehaviors when adding things to the page. I tried changing the script to use a queue that is processed every 50ms instead of as soon as the node is added to the DOM, so we add like 80+ placeholders at once. Because we process each ajax command separately it doesn't change much, just increases the time spent in scripting. I also tried to group the batched commands into one call of the ajaxobject.success method. Same thing, each command need to run attachbehavior individually so it doesn't help.
All in all there might be a bit more cpu usage but it might just be because it uses the CPU more efficiently. It seems we gain a few ms overall when using mutation observer but it's not significant enough to be impacting user experience. This is good to go as is just for the easier to understand code.
Comment #107
nod_That book test sure is annoying
Comment #108
Wim LeersThanks for doing that deep testing!
Barely a CPU difference when rendering the rather extreme case of 100 placeholders … that sounds like the kind of validation @lauriii requested in #104 😊
Also: memory consumption is lower — from 2.4 MB down to 2.1 MB (~12% lower). Which means we can reasonably assume there are likely fewer cache evictions in the browser on lower end hardware with this change too. Nice win!
I think this is ready :)
Comment #109
justafishComment #110
justafishThis is a great improvement, using a Mutation Observer is a much more performant approach and it's good to see us using a more appropriate native browser API. Patch looks good - also tested it manually and it works as expected - +1
Comment #112
lauriiiCommitted 7bf0b38 and pushed to 10.1.x. Thanks!
Comment #113
lauriiiComment #115
alexpottThis has resulted in a bug in Olivero - see #3319325: Olivero: Mobile menu does not work when authenticated and BigPipe enabled(D10 only). I discussed this with @nod_ and we agreed that ideally this issue should not result in a breaking change. @nod_ will file a followup issue to:
Comment #116
andypostThere's #3160052: Initial argument passed to Drupal behaviors is incorrect