Make WordPress Core

Changeset 55847


Ignore:
Timestamp:
05/22/2023 07:11:36 PM (16 months ago)
Author:
flixos90
Message:

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 #58211.
See #53675, #56930.

Location:
trunk
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/class-wp-query.php

    r55822 r55847  
    108108     */
    109109    public $current_post = -1;
     110
     111    /**
     112     * Whether the caller is before the loop.
     113     *
     114     * @since 6.3.0
     115     * @var bool
     116     */
     117    public $before_loop = true;
    110118
    111119    /**
     
    518526        $this->current_post = -1;
    519527        $this->in_the_loop  = false;
     528        $this->before_loop  = true;
    520529        unset( $this->request );
    521530        unset( $this->post );
     
    36323641
    36333642        $this->in_the_loop = true;
     3643        $this->before_loop = false;
    36343644
    36353645        if ( -1 == $this->current_post ) { // Loop has just started.
     
    36723682            $this->rewind_posts();
    36733683        } elseif ( 0 === $this->post_count ) {
     3684            $this->before_loop = false;
     3685
    36743686            /**
    36753687             * Fires if no results are found in a post query.
  • trunk/src/wp-includes/media.php

    r55843 r55847  
    54915491 * @since 5.9.0
    54925492 *
     5493 * @global WP_Query $wp_query WordPress Query object.
     5494 *
    54935495 * @param string $context Context for the element for which the `loading` attribute value is requested.
    54945496 * @return string|bool The default `loading` attribute value. Either 'lazy', 'eager', or a boolean `false`, to indicate
     
    54965498 */
    54975499function wp_get_loading_attr_default( $context ) {
     5500    global $wp_query;
     5501
    54985502    // Skip lazy-loading for the overall block template, as it is handled more granularly.
    54995503    if ( 'template' === $context ) {
     
    55025506
    55035507    // Do not lazy-load images in the header block template part, as they are likely above the fold.
     5508    // For classic themes, this is handled in the condition below using the 'get_header' action.
    55045509    $header_area = WP_TEMPLATE_PART_AREA_HEADER;
    55055510    if ( "template_part_{$header_area}" === $context ) {
     
    55075512    }
    55085513
    5509     /*
    5510      * Skip programmatically created images within post content as they need to be handled together with the other
    5511      * images within the post content.
    5512      * Without this clause, they would already be counted below which skews the number and can result in the first
    5513      * post content image being lazy-loaded only because there are images elsewhere in the post content.
    5514      */
    5515     if ( ( 'the_post_thumbnail' === $context || 'wp_get_attachment_image' === $context ) && doing_filter( 'the_content' ) ) {
    5516         return false;
     5514    // Special handling for programmatically created image tags.
     5515    if ( ( 'the_post_thumbnail' === $context || 'wp_get_attachment_image' === $context ) ) {
     5516        /*
     5517         * Skip programmatically created images within post content as they need to be handled together with the other
     5518         * images within the post content.
     5519         * Without this clause, they would already be counted below which skews the number and can result in the first
     5520         * post content image being lazy-loaded only because there are images elsewhere in the post content.
     5521         */
     5522        if ( doing_filter( 'the_content' ) ) {
     5523            return false;
     5524        }
     5525
     5526        // Conditionally skip lazy-loading on images before the loop.
     5527        if (
     5528            // Only apply for main query but before the loop.
     5529            $wp_query->before_loop && $wp_query->is_main_query()
     5530            /*
     5531             * Any image before the loop, but after the header has started should not be lazy-loaded,
     5532             * except when the footer has already started which can happen when the current template
     5533             * does not include any loop.
     5534             */
     5535            && did_action( 'get_header' ) && ! did_action( 'get_footer' )
     5536        ) {
     5537            return false;
     5538        }
    55175539    }
    55185540
  • trunk/tests/phpunit/tests/media.php

    r55825 r55847  
    35603560     */
    35613561    public function test_wp_get_loading_attr_default( $context ) {
    3562         global $wp_query, $wp_the_query;
    3563 
    35643562        // Return 'lazy' by default.
    35653563        $this->assertSame( 'lazy', wp_get_loading_attr_default( 'test' ) );
     
    35693567        $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
    35703568
    3571         $wp_query = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
     3569        $query = $this->get_new_wp_query_for_published_post();
    35723570        $this->reset_content_media_count();
    35733571        $this->reset_omit_loading_attr_filter();
     
    35803578
    35813579            // Set as main query.
    3582             $wp_the_query = $wp_query;
     3580            $this->set_main_query( $query );
    35833581
    35843582            // For contexts other than for the main content, still return 'lazy' even in the loop
     
    36143612     */
    36153613    public function test_wp_omit_loading_attr_threshold_filter() {
    3616         global $wp_query, $wp_the_query;
    3617 
    3618         $wp_query     = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
    3619         $wp_the_query = $wp_query;
     3614        $query = $this->get_new_wp_query_for_published_post();
     3615        $this->set_main_query( $query );
    36203616        $this->reset_content_media_count();
    36213617        $this->reset_omit_loading_attr_filter();
     
    36413637     */
    36423638    public function test_wp_filter_content_tags_with_wp_get_loading_attr_default() {
    3643         global $wp_query, $wp_the_query;
    3644 
    36453639        $img1         = get_image_tag( self::$large_id, '', '', '', 'large' );
    36463640        $iframe1      = '<iframe src="https://www.example.com" width="640" height="360"></iframe>';
     
    36603654        $content_expected   = wp_img_tag_add_decoding_attr( $content_expected, 'the_content' );
    36613655
    3662         $wp_query     = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
    3663         $wp_the_query = $wp_query;
     3656        $query = $this->get_new_wp_query_for_published_post();
     3657        $this->set_main_query( $query );
    36643658        $this->reset_content_media_count();
    36653659        $this->reset_omit_loading_attr_filter();
     
    36973691        $omit_threshold = wp_omit_loading_attr_threshold( true );
    36983692        $this->assertSame( 1, $omit_threshold );
     3693    }
     3694
     3695    /**
     3696     * Tests that wp_get_loading_attr_default() returns the expected loading attribute value before loop but after get_header if not main query.
     3697     *
     3698     * @ticket 58211
     3699     *
     3700     * @covers ::wp_get_loading_attr_default
     3701     *
     3702     * @dataProvider data_wp_get_loading_attr_default_before_and_no_loop
     3703     *
     3704     * @param string $context Context for the element for which the `loading` attribute value is requested.
     3705     */
     3706    public function test_wp_get_loading_attr_default_before_loop_if_not_main_query( $context ) {
     3707        global $wp_query;
     3708
     3709        $wp_query = $this->get_new_wp_query_for_published_post();
     3710        $this->reset_content_media_count();
     3711        $this->reset_omit_loading_attr_filter();
     3712
     3713        do_action( 'get_header' );
     3714
     3715        // Lazy if not main query.
     3716        $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3717    }
     3718
     3719    /**
     3720     * 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.
     3721     *
     3722     * @ticket 58211
     3723     *
     3724     * @covers ::wp_get_loading_attr_default
     3725     *
     3726     * @dataProvider data_wp_get_loading_attr_default_before_and_no_loop
     3727     *
     3728     * @param string $context Context for the element for which the `loading` attribute value is requested.
     3729     */
     3730    public function test_wp_get_loading_attr_default_before_loop_in_main_query_but_header_not_called( $context ) {
     3731        global $wp_query;
     3732
     3733        $wp_query = $this->get_new_wp_query_for_published_post();
     3734        $this->set_main_query( $wp_query );
     3735        $this->reset_content_media_count();
     3736        $this->reset_omit_loading_attr_filter();
     3737
     3738        // Lazy if header not called.
     3739        $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3740    }
     3741
     3742    /**
     3743     * Tests that wp_get_loading_attr_default() returns the expected loading attribute value before loop but after get_header for main query.
     3744     *
     3745     * @ticket 58211
     3746     *
     3747     * @covers ::wp_get_loading_attr_default
     3748     *
     3749     * @dataProvider data_wp_get_loading_attr_default_before_and_no_loop
     3750     *
     3751     * @param string $context Context for the element for which the `loading` attribute value is requested.
     3752     */
     3753    public function test_wp_get_loading_attr_default_before_loop_if_main_query( $context ) {
     3754        global $wp_query;
     3755
     3756        $wp_query = $this->get_new_wp_query_for_published_post();
     3757        $this->set_main_query( $wp_query );
     3758        $this->reset_content_media_count();
     3759        $this->reset_omit_loading_attr_filter();
     3760
     3761        do_action( 'get_header' );
     3762        $this->assertFalse( wp_get_loading_attr_default( $context ) );
     3763    }
     3764
     3765    /**
     3766     * Tests that wp_get_loading_attr_default() returns the expected loading attribute value after get_header and after loop.
     3767     *
     3768     * @ticket 58211
     3769     *
     3770     * @covers ::wp_get_loading_attr_default
     3771     *
     3772     * @dataProvider data_wp_get_loading_attr_default_before_and_no_loop
     3773     *
     3774     * @param string $context Context for the element for which the `loading` attribute value is requested.
     3775     */
     3776    public function test_wp_get_loading_attr_default_after_loop( $context ) {
     3777        global $wp_query;
     3778
     3779        $wp_query = $this->get_new_wp_query_for_published_post();
     3780        $this->set_main_query( $wp_query );
     3781        $this->reset_content_media_count();
     3782        $this->reset_omit_loading_attr_filter();
     3783
     3784        do_action( 'get_header' );
     3785
     3786        while ( have_posts() ) {
     3787            the_post();
     3788        }
     3789        $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3790    }
     3791
     3792    /**
     3793     * Tests that wp_get_loading_attr_default() returns the expected loading attribute if no loop.
     3794     *
     3795     * @ticket 58211
     3796     *
     3797     * @covers ::wp_get_loading_attr_default
     3798     *
     3799     * @dataProvider data_wp_get_loading_attr_default_before_and_no_loop
     3800     *
     3801     * @param string $context Context for the element for which the `loading` attribute value is requested.
     3802     */
     3803    public function test_wp_get_loading_attr_default_no_loop( $context ) {
     3804        global $wp_query;
     3805
     3806        $wp_query = $this->get_new_wp_query_for_published_post();
     3807        $this->set_main_query( $wp_query );
     3808        $this->reset_content_media_count();
     3809        $this->reset_omit_loading_attr_filter();
     3810
     3811        // Ensure header and footer is called.
     3812        do_action( 'get_header' );
     3813        do_action( 'get_footer' );
     3814
     3815        // Load lazy if the there is no loop and footer was called.
     3816        $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3817    }
     3818
     3819    /**
     3820     * Data provider.
     3821     *
     3822     * @return array[]
     3823     */
     3824    public function data_wp_get_loading_attr_default_before_and_no_loop() {
     3825        return array(
     3826            array( 'wp_get_attachment_image' ),
     3827            array( 'the_post_thumbnail' ),
     3828        );
    36993829    }
    37003830
     
    41674297        );
    41684298    }
     4299
     4300    /**
     4301     * Returns a new WP_Query.
     4302     *
     4303     * @global WP_Query $wp_query WordPress Query object.
     4304     *
     4305     * @return WP_Query a new query.
     4306     */
     4307    public function get_new_wp_query_for_published_post() {
     4308        global $wp_query;
     4309
     4310        // New query to $wp_query. update global for the loop.
     4311        $wp_query = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
     4312
     4313        return $wp_query;
     4314    }
     4315
     4316    /**
     4317     * Sets a query as main query.
     4318     *
     4319     * @global WP_Query $wp_the_query WordPress Query object.
     4320     *
     4321     * @param WP_Query $query query to be set as main query.
     4322     */
     4323    public function set_main_query( $query ) {
     4324        global $wp_the_query;
     4325        $wp_the_query = $query;
     4326    }
    41694327}
    41704328
  • trunk/tests/phpunit/tests/query.php

    r54891 r55847  
    898898        $this->assertFalse( $q->is_tag( 'non-existent-tag' ) );
    899899    }
     900
     901    /**
     902     * Test if $before_loop is true before loop.
     903     *
     904     * @ticket 58211
     905     */
     906    public function test_before_loop_value_set_true_before_the_loop() {
     907        // Get a new query with 3 posts.
     908        $query = $this->get_new_wp_query_with_posts( 3 );
     909
     910        $this->assertTrue( $query->before_loop );
     911    }
     912
     913    /**
     914     * Test $before_loop value is set to false when the loop starts.
     915     *
     916     * @ticket 58211
     917     *
     918     * @covers WP_Query::the_post
     919     */
     920    public function test_before_loop_value_set_to_false_in_loop_with_post() {
     921        // Get a new query with 2 posts.
     922        $query = $this->get_new_wp_query_with_posts( 2 );
     923
     924        while ( $query->have_posts() ) {
     925            // $before_loop should be set false as soon as the_post is called for the first time.
     926            $query->the_post();
     927
     928            $this->assertFalse( $query->before_loop );
     929            break;
     930        }
     931    }
     932
     933    /**
     934     * Test $before_loop value is set to false when there is no post in the loop.
     935     *
     936     * @ticket 58211
     937     *
     938     * @covers WP_Query::have_posts
     939     */
     940    public function test_before_loop_set_false_after_loop_with_no_post() {
     941        // New query without any posts in the result.
     942        $query = new WP_Query(
     943            array(
     944                'category_name' => 'non-existent-category',
     945            )
     946        );
     947
     948        // There will not be any posts, so the loop will never actually enter.
     949        while ( $query->have_posts() ) {
     950            $query->the_post();
     951        }
     952
     953        // Still, this should be false as there are no results and entering the loop was attempted.
     954        $this->assertFalse( $query->before_loop );
     955    }
     956
     957    /**
     958     * Get a new query with a given number of posts.
     959     *
     960     * @param int $no_of_posts Number of posts to be added in the query.
     961     */
     962    public function get_new_wp_query_with_posts( $no_of_posts ) {
     963        $post_ids = self::factory()->post->create_many( $no_of_posts );
     964        $query    = new WP_Query( array( 'post__in' => $post_ids ) );
     965        return $query;
     966    }
    900967}
Note: See TracChangeset for help on using the changeset viewer.