Refine lazy-loading implementation by not lazy-loading elements that are likely to appear above the fold#1580
Conversation
…are likely to appear above the fold.
|
@felixarntz have you thought about how we could have more precise heuristics going forwards that can take the semantics and structure of blocks into account to get a sense for what is actually deferrable? For example, an image block or a site logo used in a header template part would be strong indicatives of being above the fold. "The first image of the content" seems instead like a rudimentary measure, that varies a lot depending on preceding layout. With block themes we should have some ahead-of-time awareness of layout which we can use to produce more meaningful instructions. |
|
@mtias Great point, there is definitely room for further refinement as we're moving towards a world of block themes. I've been thinking about detecting the header template part as well. The analysis I've conducted using the fix here (even though technically I agree that "the first content image" sounds rudimentary) has proven that it already significantly improves performance across the board, so I think it's a useful enhancement on its own - I don't think we should overcomplicate it for now. Once there are more block-based themes available, I agree it would make sense to refine further by using the semantics of blocks. For example, we could also make decisions like "if the first block is a gallery block with three images per row, omit lazy-loading on the first three images". |
|
Also wanted to note that I found that 3,801 themes (out of 4,145) make use of the |
src/wp-includes/media.php
Outdated
| // Increase the counter since this is a main query content element. | ||
| if ( ! isset( $wp_content_media_count ) ) { | ||
| $wp_content_media_count = 0; | ||
| } | ||
| $wp_content_media_count++; |
There was a problem hiding this comment.
One complication with this approach: if a plugin happens to apply_filters('the_content', get_post()->post_content) before also doing the_content() (e.g. for analysis), then this could result in counts that are “thrown away”.
I've run into this problem where I was attempting to inline styles for the first occurence of a block only to find on a site with a table of contents plugin that the styles don't show up because they were injected while applying the_content filters without rendering the output: westonruter/syntax-highlighting-code-block#286.
See problematic code from that TOC plugin: https://github.com/openlab-at-city-tech/openlab/blob/cacf07eceb2addc1f431c6ffeae4e34b322c1899/wp-content/plugins/fixed-toc/frontend/class-frontend-control.php#L138-L142
There was a problem hiding this comment.
I suppose the worst case is that here is that more lazy-loaded images get served at the beginning of the content, which wouldn't be any worse than they are today. So probably not a big deal, but it is an edge case.
There was a problem hiding this comment.
@westonruter That's a fair point overall, but the code here has additional checks in place to make it less likely (while certainly not impossible) that that happens. For example, the TOC plugin you referenced runs that logic on wp_enqueue_scripts. With the code here, that wouldn't result in problems because it only applies when in_the_loop(), among other things (see the beginning of this function).
Co-authored-by: Weston Ruter <westonruter@google.com>
|
@felixarntz yes, I didn't mean rudimentary in a disparaging way, just that it feels we could be doing more tailored targeting there with block semantics. |
adamsilverstein
left a comment
There was a problem hiding this comment.
Overall this looks good to me. It would be good to explain in the dev notes why wp_omit_loading_attr_threshold might be useful.
Thinking out loud: it almost feels like lazy should be part of src set - some images might be in-viewport on large screens and out-of-viewport on smaller screens, if would be nice if the loading attribute could be applied with this type of media query.
|
verified working correctly in |
Co-authored-by: Weston Ruter <westonruter@google.com>
…xt even outside the loop.
That was expected behavior with the proposed fix, since that theme is rendering the featured image outside of the loop. It raises a good point though that maybe we should support that case, since it is fairly common to render the featured image on top of the page when I will run a separate follow-up A/B test to compare. Until then, happy to hear your feedback @westonruter on this new commit as well. |
|
@adamsilverstein @westonruter @ThierryA I've ran a test between both versions of the fix (see above #1580 (comment)) across the 30 most "popular" themes based on the wordpress.org API. To clarify, the two different versions are:
The difference between the two is barely noticeable:
Given that there is no win across the board from also counting featured images in singular context outside the loop and since it is also a special case anyway, I suggest we revert 03a95a9 again and go with the original approach. I don't think adding a special case just for some core themes with a non-standard implementation is justifiable. Thoughts? |
I agree your code here should be as generalized as possible and you should revert the final change. One thing to consider would be changing twentynineteen itself since we maintain that in core as well and I'm guessing it is still in very wide usage. |
…ar context even outside the loop." This reverts commit 03a95a9.
|
@adamsilverstein Reverted! We can look at changing Twenty Nineteen as well, but it might be tricky. While it should be possible to move that header content into the loop without affecting the actual design, a problem would be child themes. For example a child theme that overrides Or maybe we can actually "fake" the real loop within the |
Sounds totally reasonable to me, +1! |
|
I'm actually having some "second thoughts" on this. As far as I understand this is a browser problem. The browsers should be able to detect when an image is going to be in the viewport and load it eagerly. That's in the specs. It is somewhat unfortunate that this doesn't currently work particularly well as seen by @felixarntz's post, but I'm not 100% sure WordPress should be trying to partially remedy the situation. No matter what we do it will always be a partial, hit-and-miss solution as there's no chance to guess the viewport from the server. The browsers are much better equipped for that and should be fixing it. Another thing is that WordPress already slows things down pretty considerably by implementing Thinking it would be better to see if the browsers fix this and in case they don't/won't perhaps try to add some code on the server. My suggestion would be to come back to this in 6 months to a year and asses it again. |
The problem is that the browser is only able to detect that when the layout has been computed. This is why there is a slight delay on lazy-loaded images at the top - the browser still today loads the top images as soon as it knows they're at the top, but that is after the layout has been computed. Waiting for this layout computation is what causes the LCP regression I outlined in the post. However, we shouldn't just disable lazy-loading either, since then all images would loaded right away, which is obviously bad.
See above, the browsers aren't particularly better equipped unfortunately due to the layout computation being required. I used to believe the same thing that you're saying, but it is unfortunately not the case. I agree a partial fix isn't perfect, but it is still a partial fix, and the analysis has shown that it brings a notable performance improvement in most cases. The performance improvement things brings on the client side is vastly more relevant than a tiny bit of extra logic being run on the server (which is not particularly expensive).
I will keep pushing and we can see if at some point the browser can improve some heuristics for this, but the browsers are limited in fixing this problem per my explanation above. The enhancement here is entirely non-disruptive, there is no API change, so it could even be removed again at some point, should a better solution arise. This isn't going to be the case anytime in the not-so-distant future though. We should proactively improve performance of WordPress, not hope that somebody else will do it for us. |
|
Right, browsers are inherently limited in that they cannot foresee the future. It's impossible for them to know whether to start loading a lazily-loaded image prior to layout since they won't know if it is going to be in the viewport. WordPress can help the browser out by making educated guesses as to which images are going to be in the first viewport so that the browser doesn't have to wait for layout to occur. This is achieved by omitting the |
|
Seconding; in an ideal world, the browser would handle this. But it can't, until it's too late for it to be beneficial. Note that this is also going to start creating warnings for everybody who's checking their site in Lighthouse: https://developer.chrome.com/blog/new-in-devtools-95/#lighthouse Note also that we're also expecting priority hints to be a thing soon (see https://www.chromestatus.com/feature/5273474901737472#:~:text=Priority%20Hints%20provide%20developers%20a,preload%20status%20of%20a%20resource.); that firmly solidifies the direction of travel as "tell the browser how it should handle resources", not, "hope that this is fixed by magic". |
|
I am in favor of prioritizing the end user experience vs the ideal implementation details (without ignoring its cost indeed).
There is a clear improvement in most cases.
While the implementation is not perfect, its cost is very low and non disruptive. To me it is a clear cut, the pros outgrows the cons. |
…o control static count.
|
@azaozz If we make these variables static, there still has to be a way to reset them. There is no way to make WP's function-based code 100% bulletproof to other developers - if they want to misuse/break it they can already in a million ways. Your latest commit broke the tests because now the filter isn't re-triggered for new tests. So again, I don't disagree with the approach of making it static, but it doesn't work just like that. There has to be a way to "reset" the filter. I suggest we put it into a function like |
Hmm, yes but, why? We have to change the functionality just for the sake of testing? Shouldn't be the other way round -- make this work the best way possible then add/fix/tweak the tests to work with the code.
Yeah, was looking at how to "suggest" changes, i.e. make a variant of the existing patch. However that seems impossible on GH. The only way would be to make another PR which is not good in this case. But you're right, should have added a comment explaining it. Basically I'm thinking that both changes should be made inside I'll look at changing/fixing the tests later today and propose another change (well, commit another change as per the above). Then we can see/decide :) |
I overall agree with that statement. However, code still has to be written in a testable way if we want to add tests for it - and having state which cannot be controlled in tests prevents that (at a minimum we need to allow reset between tests). We simply cannot "lock" the code so that nobody can override it, as otherwise we also lock it for tests. What we can do is define solid patterns to make it less likely to happen "by accident", and to your point the approach with static variables and dedicated functions makes the access more intentional than just having a global.
See above, I don't think there's a way to make this entirely "locked" static code testable without reducing our test coverage. The reason most WP core tests work out of the box without any of this extra "magic" is that core uses globals, and those can always easily be overwritten, and they automatically get reset between tests. Anywhere where we don't use globals, a special mechanism is needed.
Curious what you come up with. I'll for now update the PR based on your latest commit to make this static mutable again to "unlock" it. With all that said, the discussion we're having here talks about general code and test patterns in WP core, so I'm not sure this issue is the proper place to have it. Core uses globals throughout, so from that perspective I'd even be in favor of sticking with the approach. If we change this here, it doesn't mean anything, the rest of the codebase will still follow the old pattern, and almost no other core developers will be aware of the new pattern unless it is explicitly called out e.g. in coding standards documentation - it could even be that some folks don't agree with it, another reason not to silo this wide-reaching topic discussion in a very specific PR like this. |
|
Another option for making memoization testable is to move the code into a class and making the memoization value a property. If the class is used as a singleton, the static is not needed; else, it is. That's a design choice. Tests can access the property via reflection or by instantiating a new instance for each test. Advantages to this approach are:
|
Right, the question here is what are we testing. As the
Hm, we are not "locking" the code, think of The current code as of 1c218d7 does not achieve these objectives:
Hmm, not sure these are "new" things. It's best if tests cover the intended usage, that has been the case for a long time. The current/old tests do not follow the intended usage exactly any more, so they will need adjusting. The need to change the tests arises from making some changes to the code, namely the
Yes, this would have worked if that code was in a class/wrapper. However changing this is a major refactoring considering back-compat, etc. It may happen one day, but not for 5.9. This patch is a relatively small correction/enhancement to how WordPress adds the |
Agreed, we should be testing that as well, this requires adding one more test. Update: I've added one in b0addfa.
See my previous comment. What you are proposing here makes this code untestable, except if the WP core test suite was starting a new PHP process for every single test which... would make the tests take hours, if not days. I understand this is similar to a private property of a class - to @hellofromtonya's point, that approach would be an appropriate solution, and this is how most sophisticated PHP projects do it. Still, I agree this is too much of a refactoring which this PR is not the place for. The limitation in the approach you're proposing is that the point of only running once per page-load only applies to that, a page-load. However, PHPUnit tests aren't like a page load, they run tons of things that would normally happen through tons of page loads in a single run. If a theme or plugin does
This statement confirms to me that this is not the right place to make this change. We are speaking about a change in coding paradigm that requires refactoring a ton of existing tests and also the related production code - basically "remove globals and manage state elsewhere in a testable way", which is surely not something we can or should arbitrarily decide in an unrelated PR. I think we should revert this back to using globals - we could then still change the code so that the filter runs once per page-load, while still keeping it testable. |
Hmmm, not exactly? Lets look at the tests. The problem with the test for the Same for the
Uh, sorry but I don't seem to understand exactly what you want to say. What's happening with this patch is: you asked for a review, I did a review, this review established that some changes are needed, the changes were implemented. What's left is to remove the old tests that were written with the old code in mind, and to add new tests that cover the new code. What am I missing? |
The filter being applied as expected is being tested. If we can only call it once, we are unable to test that. We could remove that test, but then the production code function could literally be
The tests were already updated to fit the new code, they still test that the filter is applied correctly, and they still pass. |
Ummm, you mean for a filter to be tested is has to run multiple times? Why? We know the default value and we know the filtered value. Running the filter is supposed to change the default to the filtered value. What's the point in running the same test again?
If I'm not mistaken that update should have included a change to exclude use of the (now supposedly "private") |
Technically, if we only run the filter once (e.g. with returning There is surely some way to make the tests all pass without a reset, but it would make them hard to understand as well unstable (e.g. what if they run in a different order), since it would pollute the overall state which is a big no-go in tests. Global state needs to be reset between tests so that we mirror production behavior, i.e. ensuring the filter runs once per test which is the test "equivalent" of per page load. Today, after every core test, all globals are reset, all filters and actions are removed, etc. We have to ensure here as well that the state modified via one test doesn't affect another, that's why a simple |
Right, testing "static caching" is a bit different and somewhat harder. Think of it as testing PHP constants: they need to be tested if they are set, and are set to the proper values, but no other tests are needed as they can never be changed later. So generally they require less testing that variables, etc.
Yes, and the tests here do exactly the same: there are no globals, and the filter is removed. However there is a constant that is set by WordPress when using this functionality. It cannot be reset which is the proper behavior as that's exactly what happens in production. In any case, you're an experienced developer and know this part of the code very well. We worked really well together when implementing the main functionality. This is a relatively small (by code size) enhancement. Lets add it as-is if you think it's fine. |
|
@azaozz It's a good point to bring up the comparison with constants, particularly for the filter which shouldn't change its value on page load. The thing with those though is that they're typically defined early on at runtime (e.g. in That said, it briefly made me think about whether we should actually have a constant here instead of a filter. That probably wouldn't be flexible enough though since the filter may be used in different ways e.g. based on which template is currently being loaded, which we wouldn't be able to do with a constant. Thanks for trusting my judgement here. We're indeed nit-picking about a rather minor part of the functionality, so we can probably move this forward. |
Right, thinking a filter is a lot better there, as it is now.
Exactly right 👍 |
|
Committed in https://core.trac.wordpress.org/changeset/52065. |

This PR implements the refinement described in this post as a core enhancement.
wp_get_loading_attr_default( $context )that should be used in place of the hard-coded 'lazy' string defaults used throughout the codebase so far.the_contentandthe_post_thumbnail(new, see below) it returnsfalse(to omit theloadingattribute) for the very first image or iframe within the main query loop.wp_omit_loading_attr_thresholdin order to allow fine tuning of the default threshold of 1 content image or iframe (e.g. based on special layout usage by a theme). For example, if the filter is used to set the threshold to 3, the first 3 images or iframes won't be lazy-loaded.the_post_thumbnailis introduced, used withinget_the_post_thumbnail()and thus taking precedence over thewp_get_attachment_imagecontext that would be used bywp_get_attachment_image().wp_filter_content_tags():loadingattribute, the function now goes over all the matches in their correct order. Nothing else has changed in the logic there, and no existing tests are affected by this change.Trac ticket: https://core.trac.wordpress.org/ticket/53675
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.