Media: Conditionally skip lazy-loading on images before the loop to improve LCP performance.

When the logic to exclude images that likely appear above the fold from being lazy-loaded was introduced in WordPress 5.9, initially only images that appear within the main query loop were being considered. However, there is a good chance that images above the fold are rendered before the loop starts, for example in the header template part.

It is particularly common for a theme to display the featured image for a single post in the header. Based on HTTP Archive data from February 2023, the majority of LCP images that are still being lazy-loaded on WordPress sites use the `wp-post-image` class, i.e. are featured images.

This changeset enhances the logic in `wp_get_loading_attr_default()` to not lazy-load images that appear within or after the header template part and before the query loop, using a new `WP_Query::$before_loop` property.

For block themes, this was for the most part already addressed in [55318], however this enhancement implements the solution in a more generally applicable way that brings the improvement to classic themes as well.

Props thekt12, flixos90, spacedmonkey, costdev, zunaid321, mukesh27.
Fixes .
See , .


git-svn-id: https://develop.svn.wordpress.org/trunk@55847 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Felix Arntz 2023-05-22 19:11:36 +00:00
parent 3c6184d81c
commit 71140f327f
4 changed files with 279 additions and 20 deletions
src/wp-includes
tests/phpunit/tests

@ -108,6 +108,14 @@ class WP_Query {
*/
public $current_post = -1;
/**
* Whether the caller is before the loop.
*
* @since 6.3.0
* @var bool
*/
public $before_loop = true;
/**
* Whether the loop has started and the caller is in the loop.
*
@ -517,6 +525,7 @@ class WP_Query {
$this->post_count = 0;
$this->current_post = -1;
$this->in_the_loop = false;
$this->before_loop = true;
unset( $this->request );
unset( $this->post );
unset( $this->comments );
@ -3631,6 +3640,7 @@ class WP_Query {
}
$this->in_the_loop = true;
$this->before_loop = false;
if ( -1 == $this->current_post ) { // Loop has just started.
/**
@ -3671,6 +3681,8 @@ class WP_Query {
// Do some cleaning up after the loop.
$this->rewind_posts();
} elseif ( 0 === $this->post_count ) {
$this->before_loop = false;
/**
* Fires if no results are found in a post query.
*

@ -5490,30 +5490,52 @@ function wp_get_webp_info( $filename ) {
*
* @since 5.9.0
*
* @global WP_Query $wp_query WordPress Query object.
*
* @param string $context Context for the element for which the `loading` attribute value is requested.
* @return string|bool The default `loading` attribute value. Either 'lazy', 'eager', or a boolean `false`, to indicate
* that the `loading` attribute should be skipped.
*/
function wp_get_loading_attr_default( $context ) {
global $wp_query;
// Skip lazy-loading for the overall block template, as it is handled more granularly.
if ( 'template' === $context ) {
return false;
}
// Do not lazy-load images in the header block template part, as they are likely above the fold.
// For classic themes, this is handled in the condition below using the 'get_header' action.
$header_area = WP_TEMPLATE_PART_AREA_HEADER;
if ( "template_part_{$header_area}" === $context ) {
return false;
}
/*
* Skip programmatically created images within post content as they need to be handled together with the other
* images within the post content.
* Without this clause, they would already be counted below which skews the number and can result in the first
* post content image being lazy-loaded only because there are images elsewhere in the post content.
*/
if ( ( 'the_post_thumbnail' === $context || 'wp_get_attachment_image' === $context ) && doing_filter( 'the_content' ) ) {
return false;
// Special handling for programmatically created image tags.
if ( ( 'the_post_thumbnail' === $context || 'wp_get_attachment_image' === $context ) ) {
/*
* Skip programmatically created images within post content as they need to be handled together with the other
* images within the post content.
* Without this clause, they would already be counted below which skews the number and can result in the first
* post content image being lazy-loaded only because there are images elsewhere in the post content.
*/
if ( doing_filter( 'the_content' ) ) {
return false;
}
// Conditionally skip lazy-loading on images before the loop.
if (
// Only apply for main query but before the loop.
$wp_query->before_loop && $wp_query->is_main_query()
/*
* Any image before the loop, but after the header has started should not be lazy-loaded,
* except when the footer has already started which can happen when the current template
* does not include any loop.
*/
&& did_action( 'get_header' ) && ! did_action( 'get_footer' )
) {
return false;
}
}
/*

@ -3559,8 +3559,6 @@ EOF;
* @param string $context
*/
public function test_wp_get_loading_attr_default( $context ) {
global $wp_query, $wp_the_query;
// Return 'lazy' by default.
$this->assertSame( 'lazy', wp_get_loading_attr_default( 'test' ) );
$this->assertSame( 'lazy', wp_get_loading_attr_default( 'wp_get_attachment_image' ) );
@ -3568,7 +3566,7 @@ EOF;
// Return 'lazy' if not in the loop or the main query.
$this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
$wp_query = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
$query = $this->get_new_wp_query_for_published_post();
$this->reset_content_media_count();
$this->reset_omit_loading_attr_filter();
@ -3579,7 +3577,7 @@ EOF;
$this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
// Set as main query.
$wp_the_query = $wp_query;
$this->set_main_query( $query );
// For contexts other than for the main content, still return 'lazy' even in the loop
// and in the main query, and do not increase the content media count.
@ -3613,10 +3611,8 @@ EOF;
* @ticket 53675
*/
public function test_wp_omit_loading_attr_threshold_filter() {
global $wp_query, $wp_the_query;
$wp_query = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
$wp_the_query = $wp_query;
$query = $this->get_new_wp_query_for_published_post();
$this->set_main_query( $query );
$this->reset_content_media_count();
$this->reset_omit_loading_attr_filter();
@ -3640,8 +3636,6 @@ EOF;
* @ticket 53675
*/
public function test_wp_filter_content_tags_with_wp_get_loading_attr_default() {
global $wp_query, $wp_the_query;
$img1 = get_image_tag( self::$large_id, '', '', '', 'large' );
$iframe1 = '<iframe src="https://www.example.com" width="640" height="360"></iframe>';
$img2 = get_image_tag( self::$large_id, '', '', '', 'medium' );
@ -3659,8 +3653,8 @@ EOF;
$content_expected = $img1 . $iframe1 . $lazy_img2 . $lazy_img3 . $lazy_iframe2;
$content_expected = wp_img_tag_add_decoding_attr( $content_expected, 'the_content' );
$wp_query = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
$wp_the_query = $wp_query;
$query = $this->get_new_wp_query_for_published_post();
$this->set_main_query( $query );
$this->reset_content_media_count();
$this->reset_omit_loading_attr_filter();
@ -3698,6 +3692,142 @@ EOF;
$this->assertSame( 1, $omit_threshold );
}
/**
* Tests that wp_get_loading_attr_default() returns the expected loading attribute value before loop but after get_header if not main query.
*
* @ticket 58211
*
* @covers ::wp_get_loading_attr_default
*
* @dataProvider data_wp_get_loading_attr_default_before_and_no_loop
*
* @param string $context Context for the element for which the `loading` attribute value is requested.
*/
public function test_wp_get_loading_attr_default_before_loop_if_not_main_query( $context ) {
global $wp_query;
$wp_query = $this->get_new_wp_query_for_published_post();
$this->reset_content_media_count();
$this->reset_omit_loading_attr_filter();
do_action( 'get_header' );
// Lazy if not main query.
$this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
}
/**
* Tests that wp_get_loading_attr_default() returns the expected loading attribute value before loop but after get_header in main query but header was not called.
*
* @ticket 58211
*
* @covers ::wp_get_loading_attr_default
*
* @dataProvider data_wp_get_loading_attr_default_before_and_no_loop
*
* @param string $context Context for the element for which the `loading` attribute value is requested.
*/
public function test_wp_get_loading_attr_default_before_loop_in_main_query_but_header_not_called( $context ) {
global $wp_query;
$wp_query = $this->get_new_wp_query_for_published_post();
$this->set_main_query( $wp_query );
$this->reset_content_media_count();
$this->reset_omit_loading_attr_filter();
// Lazy if header not called.
$this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
}
/**
* Tests that wp_get_loading_attr_default() returns the expected loading attribute value before loop but after get_header for main query.
*
* @ticket 58211
*
* @covers ::wp_get_loading_attr_default
*
* @dataProvider data_wp_get_loading_attr_default_before_and_no_loop
*
* @param string $context Context for the element for which the `loading` attribute value is requested.
*/
public function test_wp_get_loading_attr_default_before_loop_if_main_query( $context ) {
global $wp_query;
$wp_query = $this->get_new_wp_query_for_published_post();
$this->set_main_query( $wp_query );
$this->reset_content_media_count();
$this->reset_omit_loading_attr_filter();
do_action( 'get_header' );
$this->assertFalse( wp_get_loading_attr_default( $context ) );
}
/**
* Tests that wp_get_loading_attr_default() returns the expected loading attribute value after get_header and after loop.
*
* @ticket 58211
*
* @covers ::wp_get_loading_attr_default
*
* @dataProvider data_wp_get_loading_attr_default_before_and_no_loop
*
* @param string $context Context for the element for which the `loading` attribute value is requested.
*/
public function test_wp_get_loading_attr_default_after_loop( $context ) {
global $wp_query;
$wp_query = $this->get_new_wp_query_for_published_post();
$this->set_main_query( $wp_query );
$this->reset_content_media_count();
$this->reset_omit_loading_attr_filter();
do_action( 'get_header' );
while ( have_posts() ) {
the_post();
}
$this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
}
/**
* Tests that wp_get_loading_attr_default() returns the expected loading attribute if no loop.
*
* @ticket 58211
*
* @covers ::wp_get_loading_attr_default
*
* @dataProvider data_wp_get_loading_attr_default_before_and_no_loop
*
* @param string $context Context for the element for which the `loading` attribute value is requested.
*/
public function test_wp_get_loading_attr_default_no_loop( $context ) {
global $wp_query;
$wp_query = $this->get_new_wp_query_for_published_post();
$this->set_main_query( $wp_query );
$this->reset_content_media_count();
$this->reset_omit_loading_attr_filter();
// Ensure header and footer is called.
do_action( 'get_header' );
do_action( 'get_footer' );
// Load lazy if the there is no loop and footer was called.
$this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
}
/**
* Data provider.
*
* @return array[]
*/
public function data_wp_get_loading_attr_default_before_and_no_loop() {
return array(
array( 'wp_get_attachment_image' ),
array( 'the_post_thumbnail' ),
);
}
/**
* Tests that wp_filter_content_tags() does not add loading="lazy" to the first
* image in the loop when using a block theme.
@ -4166,6 +4296,34 @@ EOF;
}
);
}
/**
* Returns a new WP_Query.
*
* @global WP_Query $wp_query WordPress Query object.
*
* @return WP_Query a new query.
*/
public function get_new_wp_query_for_published_post() {
global $wp_query;
// New query to $wp_query. update global for the loop.
$wp_query = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
return $wp_query;
}
/**
* Sets a query as main query.
*
* @global WP_Query $wp_the_query WordPress Query object.
*
* @param WP_Query $query query to be set as main query.
*/
public function set_main_query( $query ) {
global $wp_the_query;
$wp_the_query = $query;
}
}
/**

@ -897,4 +897,71 @@ class Tests_Query extends WP_UnitTestCase {
$this->assertFalse( $q->is_tax() );
$this->assertFalse( $q->is_tag( 'non-existent-tag' ) );
}
/**
* Test if $before_loop is true before loop.
*
* @ticket 58211
*/
public function test_before_loop_value_set_true_before_the_loop() {
// Get a new query with 3 posts.
$query = $this->get_new_wp_query_with_posts( 3 );
$this->assertTrue( $query->before_loop );
}
/**
* Test $before_loop value is set to false when the loop starts.
*
* @ticket 58211
*
* @covers WP_Query::the_post
*/
public function test_before_loop_value_set_to_false_in_loop_with_post() {
// Get a new query with 2 posts.
$query = $this->get_new_wp_query_with_posts( 2 );
while ( $query->have_posts() ) {
// $before_loop should be set false as soon as the_post is called for the first time.
$query->the_post();
$this->assertFalse( $query->before_loop );
break;
}
}
/**
* Test $before_loop value is set to false when there is no post in the loop.
*
* @ticket 58211
*
* @covers WP_Query::have_posts
*/
public function test_before_loop_set_false_after_loop_with_no_post() {
// New query without any posts in the result.
$query = new WP_Query(
array(
'category_name' => 'non-existent-category',
)
);
// There will not be any posts, so the loop will never actually enter.
while ( $query->have_posts() ) {
$query->the_post();
}
// Still, this should be false as there are no results and entering the loop was attempted.
$this->assertFalse( $query->before_loop );
}
/**
* Get a new query with a given number of posts.
*
* @param int $no_of_posts Number of posts to be added in the query.
*/
public function get_new_wp_query_with_posts( $no_of_posts ) {
$post_ids = self::factory()->post->create_many( $no_of_posts );
$query = new WP_Query( array( 'post__in' => $post_ids ) );
return $query;
}
}