WordPress.org

Make WordPress Core

Changeset 52065


Ignore:
Timestamp:
11/09/2021 12:34:17 AM (4 weeks ago)
Author:
flixos90
Message:

Media: Refine the heuristics to exclude certain images and iframes from being lazy-loaded to improve performance.

This changeset implements the refined lazy-loading behavior outlined in https://make.wordpress.org/core/2021/07/15/refining-wordpress-cores-lazy-loading-implementation/ in order to improve the Largest Contentful Paint metric, which can see a regression from images or iframes above the fold being lazy-loaded. Adjusting this so far has been possible for developers via filters and still is, however this enhancement brings a more accurate behavior out of the box for the majority of themes.

Specifically, this changeset skips the very first "content image or iframe" on the page from being lazy-loaded. "Content image or iframe" denotes any image or iframe that is found within content of any post in the current main query loop as well as any featured image of such a post. This applies both to "singular" as well as "archive" content: On a "singular" page the first image/iframe of the post is not lazy-loaded, while on an "archive" page the first image/iframe of the _first_ post in the query is not lazy-loaded.

This approach refines the lazy-loading behavior correctly for the majority of themes, which use a single-column layout for post content. For themes with multi-column layouts, a new wp_omit_loading_attr_threshold filter can be used to change how many of the first images/iframes are being skipped from lazy-loaded (default is 1). For example, a theme using a three-column grid of latest posts for archives could use the filter to override the threshold to 3 on archive pages, so that the first three content images/iframes would not be lazy-loaded.

Props adamsilverstein, azaozz, flixos90, hellofromtonya, jonoaldersonwp, mte90, rviscomi, tweetythierry, westonruter.
Fixes #53675. See #50425.

Location:
trunk
Files:
4 edited

Legend:

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

    r51903 r52065  
    10471047        // Add `loading` attribute.
    10481048        if ( wp_lazy_loading_enabled( 'img', 'wp_get_attachment_image' ) ) {
    1049             $default_attr['loading'] = 'lazy';
     1049            $default_attr['loading'] = wp_get_loading_attr_default( 'wp_get_attachment_image' );
    10501050        }
    10511051
     
    18211821    }
    18221822
    1823     foreach ( $images as $image => $attachment_id ) {
    1824         $filtered_image = $image;
    1825 
    1826         // Add 'width' and 'height' attributes if applicable.
    1827         if ( $attachment_id > 0 && false === strpos( $filtered_image, ' width=' ) && false === strpos( $filtered_image, ' height=' ) ) {
    1828             $filtered_image = wp_img_tag_add_width_and_height_attr( $filtered_image, $context, $attachment_id );
    1829         }
    1830 
    1831         // Add 'srcset' and 'sizes' attributes if applicable.
    1832         if ( $attachment_id > 0 && false === strpos( $filtered_image, ' srcset=' ) ) {
    1833             $filtered_image = wp_img_tag_add_srcset_and_sizes_attr( $filtered_image, $context, $attachment_id );
    1834         }
    1835 
    1836         // Add 'loading' attribute if applicable.
    1837         if ( $add_img_loading_attr && false === strpos( $filtered_image, ' loading=' ) ) {
    1838             $filtered_image = wp_img_tag_add_loading_attr( $filtered_image, $context );
    1839         }
    1840 
    1841         if ( $filtered_image !== $image ) {
    1842             $content = str_replace( $image, $filtered_image, $content );
    1843         }
    1844     }
    1845 
    1846     foreach ( $iframes as $iframe => $attachment_id ) {
    1847         $filtered_iframe = $iframe;
    1848 
    1849         // Add 'loading' attribute if applicable.
    1850         if ( $add_iframe_loading_attr && false === strpos( $filtered_iframe, ' loading=' ) ) {
    1851             $filtered_iframe = wp_iframe_tag_add_loading_attr( $filtered_iframe, $context );
    1852         }
    1853 
    1854         if ( $filtered_iframe !== $iframe ) {
    1855             $content = str_replace( $iframe, $filtered_iframe, $content );
     1823    // Iterate through the matches in order of occurrence as it is relevant for whether or not to lazy-load.
     1824    foreach ( $matches as $match ) {
     1825        // Filter an image match.
     1826        if ( isset( $images[ $match[0] ] ) ) {
     1827            $filtered_image = $match[0];
     1828            $attachment_id  = $images[ $match[0] ];
     1829
     1830            // Add 'width' and 'height' attributes if applicable.
     1831            if ( $attachment_id > 0 && false === strpos( $filtered_image, ' width=' ) && false === strpos( $filtered_image, ' height=' ) ) {
     1832                $filtered_image = wp_img_tag_add_width_and_height_attr( $filtered_image, $context, $attachment_id );
     1833            }
     1834
     1835            // Add 'srcset' and 'sizes' attributes if applicable.
     1836            if ( $attachment_id > 0 && false === strpos( $filtered_image, ' srcset=' ) ) {
     1837                $filtered_image = wp_img_tag_add_srcset_and_sizes_attr( $filtered_image, $context, $attachment_id );
     1838            }
     1839
     1840            // Add 'loading' attribute if applicable.
     1841            if ( $add_img_loading_attr && false === strpos( $filtered_image, ' loading=' ) ) {
     1842                $filtered_image = wp_img_tag_add_loading_attr( $filtered_image, $context );
     1843            }
     1844
     1845            if ( $filtered_image !== $match[0] ) {
     1846                $content = str_replace( $match[0], $filtered_image, $content );
     1847            }
     1848        }
     1849
     1850        // Filter an iframe match.
     1851        if ( isset( $iframes[ $match[0] ] ) ) {
     1852            $filtered_iframe = $match[0];
     1853
     1854            // Add 'loading' attribute if applicable.
     1855            if ( $add_iframe_loading_attr && false === strpos( $filtered_iframe, ' loading=' ) ) {
     1856                $filtered_iframe = wp_iframe_tag_add_loading_attr( $filtered_iframe, $context );
     1857            }
     1858
     1859            if ( $filtered_iframe !== $match[0] ) {
     1860                $content = str_replace( $match[0], $filtered_iframe, $content );
     1861            }
    18561862        }
    18571863    }
     
    18701876 */
    18711877function wp_img_tag_add_loading_attr( $image, $context ) {
     1878    // Get loading attribute value to use. This must occur before the conditional check below so that even images that
     1879    // are ineligible for being lazy-loaded are considered.
     1880    $value = wp_get_loading_attr_default( $context );
     1881
    18721882    // Images should have source and dimension attributes for the `loading` attribute to be added.
    18731883    if ( false === strpos( $image, ' src="' ) || false === strpos( $image, ' width="' ) || false === strpos( $image, ' height="' ) ) {
     
    18841894     *
    18851895     * @param string|bool $value   The `loading` attribute value. Returning a falsey value will result in
    1886      *                             the attribute being omitted for the image. Default 'lazy'.
     1896     *                             the attribute being omitted for the image.
    18871897     * @param string      $image   The HTML `img` tag to be filtered.
    18881898     * @param string      $context Additional context about how the function was called or where the img tag is.
    18891899     */
    1890     $value = apply_filters( 'wp_img_tag_add_loading_attr', 'lazy', $image, $context );
     1900    $value = apply_filters( 'wp_img_tag_add_loading_attr', $value, $image, $context );
    18911901
    18921902    if ( $value ) {
     
    19962006    }
    19972007
     2008    // Get loading attribute value to use. This must occur before the conditional check below so that even iframes that
     2009    // are ineligible for being lazy-loaded are considered.
     2010    $value = wp_get_loading_attr_default( $context );
     2011
    19982012    // Iframes should have source and dimension attributes for the `loading` attribute to be added.
    19992013    if ( false === strpos( $iframe, ' src="' ) || false === strpos( $iframe, ' width="' ) || false === strpos( $iframe, ' height="' ) ) {
     
    20102024     *
    20112025     * @param string|bool $value   The `loading` attribute value. Returning a falsey value will result in
    2012      *                             the attribute being omitted for the iframe. Default 'lazy'.
     2026     *                             the attribute being omitted for the iframe.
    20132027     * @param string      $iframe  The HTML `iframe` tag to be filtered.
    20142028     * @param string      $context Additional context about how the function was called or where the iframe tag is.
    20152029     */
    2016     $value = apply_filters( 'wp_iframe_tag_add_loading_attr', 'lazy', $iframe, $context );
     2030    $value = apply_filters( 'wp_iframe_tag_add_loading_attr', $value, $iframe, $context );
    20172031
    20182032    if ( $value ) {
     
    51785192    return compact( 'width', 'height', 'type' );
    51795193}
     5194
     5195/**
     5196 * Gets the default value to use for a `loading` attribute on an element.
     5197 *
     5198 * This function should only be called for a tag and context if lazy-loading is generally enabled.
     5199 *
     5200 * The function usually returns 'lazy', but uses certain heuristics to guess whether the current element is likely to
     5201 * appear above the fold, in which case it returns a boolean `false`, which will lead to the `loading` attribute being
     5202 * omitted on the element. The purpose of this refinement is to avoid lazy-loading elements that are within the initial
     5203 * viewport, which can have a negative performance impact.
     5204 *
     5205 * Under the hood, the function uses {@see wp_increase_content_media_count()} every time it is called for an element
     5206 * within the main content. If the element is the very first content element, the `loading` attribute will be omitted.
     5207 * This default threshold of 1 content element to omit the `loading` attribute for can be customized using the
     5208 * {@see 'wp_omit_loading_attr_threshold'} filter.
     5209 *
     5210 * @since 5.9.0
     5211 *
     5212 * @param string $context Context for the element for which the `loading` attribute value is requested.
     5213 * @return string|bool The default `loading` attribute value. Either 'lazy', 'eager', or a boolean `false`, to indicate
     5214 *                     that the `loading` attribute should be skipped.
     5215 */
     5216function wp_get_loading_attr_default( $context ) {
     5217    // Only elements with 'the_content' or 'the_post_thumbnail' context have special handling.
     5218    if ( 'the_content' !== $context && 'the_post_thumbnail' !== $context ) {
     5219        return 'lazy';
     5220    }
     5221
     5222    // Only elements within the main query loop have special handling.
     5223    if ( is_admin() || ! in_the_loop() || ! is_main_query() ) {
     5224        return 'lazy';
     5225    }
     5226
     5227    // Increase the counter since this is a main query content element.
     5228    $content_media_count = wp_increase_content_media_count();
     5229
     5230    // If the count so far is below the threshold, return `false` so that the `loading` attribute is omitted.
     5231    if ( $content_media_count <= wp_omit_loading_attr_threshold() ) {
     5232        return false;
     5233    }
     5234
     5235    // For elements after the threshold, lazy-load them as usual.
     5236    return 'lazy';
     5237}
     5238
     5239/**
     5240 * Gets the threshold for how many of the first content media elements to not lazy-load.
     5241 *
     5242 * This function runs the {@see 'wp_omit_loading_attr_threshold'} filter, which uses a default threshold value of 1.
     5243 * The filter is only run once per page load, unless the `$force` parameter is used.
     5244 *
     5245 * @since 5.9.0
     5246 *
     5247 * @param bool $force Optional. If set to true, the filter will be (re-)applied even if it already has been before.
     5248 *                    Default false.
     5249 * @return int The number of content media elements to not lazy-load.
     5250 */
     5251function wp_omit_loading_attr_threshold( $force = false ) {
     5252    static $omit_threshold;
     5253
     5254    // This function may be called multiple times. Run the filter only once per page load.
     5255    if ( ! isset( $omit_threshold ) || $force ) {
     5256        /**
     5257         * Filters the threshold for how many of the first content media elements to not lazy-load.
     5258         *
     5259         * For these first content media elements, the `loading` attribute will be omitted. By default, this is the case
     5260         * for only the very first content media element.
     5261         *
     5262         * @since 5.9.0
     5263         *
     5264         * @param int $omit_threshold The number of media elements where the `loading` attribute will not be added. Default 1.
     5265         */
     5266        $omit_threshold = apply_filters( 'wp_omit_loading_attr_threshold', 1 );
     5267    }
     5268
     5269    return $omit_threshold;
     5270}
     5271
     5272/**
     5273 * Increases an internal content media count variable.
     5274 *
     5275 * @since 5.9.0
     5276 * @access private
     5277 *
     5278 * @param int $amount Optional. Amount to increase by. Default 1.
     5279 * @return int The latest content media count, after the increase.
     5280 */
     5281function wp_increase_content_media_count( $amount = 1 ) {
     5282    static $content_media_count = 0;
     5283
     5284    $content_media_count += $amount;
     5285
     5286    return $content_media_count;
     5287}
  • trunk/src/wp-includes/pluggable.php

    r51301 r52065  
    26792679
    26802680        if ( wp_lazy_loading_enabled( 'img', 'get_avatar' ) ) {
    2681             $defaults['loading'] = 'lazy';
     2681            $defaults['loading'] = wp_get_loading_attr_default( 'get_avatar' );
    26822682        }
    26832683
  • trunk/src/wp-includes/post-thumbnail-template.php

    r52028 r52065  
    187187        }
    188188
     189        // Get the 'loading' attribute value to use as default, taking precedence over the default from
     190        // `wp_get_attachment_image()`.
     191        $loading = wp_get_loading_attr_default( 'the_post_thumbnail' );
     192
     193        // Add the default to the given attributes unless they already include a 'loading' directive.
     194        if ( empty( $attr ) ) {
     195            $attr = array( 'loading' => $loading );
     196        } elseif ( is_array( $attr ) && ! array_key_exists( 'loading', $attr ) ) {
     197            $attr['loading'] = $loading;
     198        } elseif ( is_string( $attr ) && ! preg_match( '/(^|&)loading=', $attr ) ) {
     199            $attr .= '&loading=' . $loading;
     200        }
     201
    189202        $html = wp_get_attachment_image( $post_thumbnail_id, $size, false, $attr );
    190203
  • trunk/tests/phpunit/tests/media.php

    r52010 r52065  
    30253025     * @ticket 50425
    30263026     * @ticket 53463
     3027     * @ticket 53675
    30273028     * @dataProvider data_wp_lazy_loading_enabled_context_defaults
    30283029     *
     
    30473048            'get_avatar => true'              => array( 'get_avatar', true ),
    30483049            'arbitrary context => true'       => array( 'something_completely_arbitrary', true ),
     3050            'the_post_thumbnail => true'      => array( 'the_post_thumbnail', true ),
    30493051        );
    30503052    }
     
    31873189        );
    31883190    }
     3191
     3192    /**
     3193     * @ticket 53675
     3194     * @dataProvider data_wp_get_loading_attr_default
     3195     *
     3196     * @param string $context
     3197     */
     3198    function test_wp_get_loading_attr_default( $context ) {
     3199        global $wp_query, $wp_the_query;
     3200
     3201        // Return 'lazy' by default.
     3202        $this->assertSame( 'lazy', wp_get_loading_attr_default( 'test' ) );
     3203        $this->assertSame( 'lazy', wp_get_loading_attr_default( 'wp_get_attachment_image' ) );
     3204
     3205        // Return 'lazy' if not in the loop or the main query.
     3206        $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3207
     3208        $wp_query = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
     3209        $this->reset_content_media_count();
     3210        $this->reset_omit_loading_attr_filter();
     3211
     3212        while ( have_posts() ) {
     3213            the_post();
     3214
     3215            // Return 'lazy' if in the loop but not in the main query.
     3216            $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3217
     3218            // Set as main query.
     3219            $wp_the_query = $wp_query;
     3220
     3221            // For contexts other than for the main content, still return 'lazy' even in the loop
     3222            // and in the main query, and do not increase the content media count.
     3223            $this->assertSame( 'lazy', wp_get_loading_attr_default( 'wp_get_attachment_image' ) );
     3224
     3225            // Return `false` if in the loop and in the main query and it is the first element.
     3226            $this->assertFalse( wp_get_loading_attr_default( $context ) );
     3227
     3228            // Return 'lazy' if in the loop and in the main query for any subsequent elements.
     3229            $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3230
     3231            // Yes, for all subsequent elements.
     3232            $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) );
     3233        }
     3234    }
     3235
     3236    function data_wp_get_loading_attr_default() {
     3237        return array(
     3238            array( 'the_content' ),
     3239            array( 'the_post_thumbnail' ),
     3240        );
     3241    }
     3242
     3243    /**
     3244     * @ticket 53675
     3245     */
     3246    function test_wp_omit_loading_attr_threshold_filter() {
     3247        global $wp_query, $wp_the_query;
     3248
     3249        $wp_query     = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
     3250        $wp_the_query = $wp_query;
     3251        $this->reset_content_media_count();
     3252        $this->reset_omit_loading_attr_filter();
     3253
     3254        // Use the filter to alter the threshold for not lazy-loading to the first three elements.
     3255        add_filter(
     3256            'wp_omit_loading_attr_threshold',
     3257            function() {
     3258                return 3;
     3259            }
     3260        );
     3261
     3262        while ( have_posts() ) {
     3263            the_post();
     3264
     3265            // Due to the filter, now the first three elements should not be lazy-loaded, i.e. return `false`.
     3266            for ( $i = 0; $i < 3; $i++ ) {
     3267                $this->assertFalse( wp_get_loading_attr_default( 'the_content' ) );
     3268            }
     3269
     3270            // For following elements, lazy-load them again.
     3271            $this->assertSame( 'lazy', wp_get_loading_attr_default( 'the_content' ) );
     3272        }
     3273    }
     3274
     3275    /**
     3276     * @ticket 53675
     3277     */
     3278    function test_wp_filter_content_tags_with_wp_get_loading_attr_default() {
     3279        global $wp_query, $wp_the_query;
     3280
     3281        $img1         = get_image_tag( self::$large_id, '', '', '', 'large' );
     3282        $iframe1      = '<iframe src="https://www.example.com" width="640" height="360"></iframe>';
     3283        $img2         = get_image_tag( self::$large_id, '', '', '', 'medium' );
     3284        $img3         = get_image_tag( self::$large_id, '', '', '', 'thumbnail' );
     3285        $iframe2      = '<iframe src="https://wordpress.org" width="640" height="360"></iframe>';
     3286        $lazy_img2    = wp_img_tag_add_loading_attr( $img2, 'the_content' );
     3287        $lazy_img3    = wp_img_tag_add_loading_attr( $img3, 'the_content' );
     3288        $lazy_iframe2 = wp_iframe_tag_add_loading_attr( $iframe2, 'the_content' );
     3289
     3290        // Use a threshold of 2.
     3291        add_filter(
     3292            'wp_omit_loading_attr_threshold',
     3293            function() {
     3294                return 2;
     3295            }
     3296        );
     3297
     3298        // Following the threshold of 2, the first two content media elements should not be lazy-loaded.
     3299        $content_unfiltered = $img1 . $iframe1 . $img2 . $img3 . $iframe2;
     3300        $content_expected   = $img1 . $iframe1 . $lazy_img2 . $lazy_img3 . $lazy_iframe2;
     3301
     3302        $wp_query     = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) );
     3303        $wp_the_query = $wp_query;
     3304        $this->reset_content_media_count();
     3305        $this->reset_omit_loading_attr_filter();
     3306
     3307        while ( have_posts() ) {
     3308            the_post();
     3309
     3310            add_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' );
     3311            $content_filtered = wp_filter_content_tags( $content_unfiltered, 'the_content' );
     3312            remove_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' );
     3313        }
     3314
     3315        // After filtering, the first image should not be lazy-loaded while the other ones should be.
     3316        $this->assertSame( $content_expected, $content_filtered );
     3317    }
     3318
     3319    /**
     3320     * @ticket 53675
     3321     */
     3322    public function test_wp_omit_loading_attr_threshold() {
     3323        $this->reset_omit_loading_attr_filter();
     3324
     3325        // Apply filter, ensure default value of 1.
     3326        $omit_threshold = wp_omit_loading_attr_threshold();
     3327        $this->assertSame( 1, $omit_threshold );
     3328
     3329        // Add a filter that changes the value to 3. However, the filter is not applied a subsequent time in a single
     3330        // page load by default, so the value is still 1.
     3331        add_filter(
     3332            'wp_omit_loading_attr_threshold',
     3333            function() {
     3334                return 3;
     3335            }
     3336        );
     3337        $omit_threshold = wp_omit_loading_attr_threshold();
     3338        $this->assertSame( 1, $omit_threshold );
     3339
     3340        // Only by enforcing a fresh check, the filter gets re-applied.
     3341        $omit_threshold = wp_omit_loading_attr_threshold( true );
     3342        $this->assertSame( 3, $omit_threshold );
     3343    }
     3344
     3345    private function reset_content_media_count() {
     3346        // Get current value without increasing.
     3347        $content_media_count = wp_increase_content_media_count( 0 );
     3348
     3349        // Decrease it by its current value to "reset" it back to 0.
     3350        wp_increase_content_media_count( - $content_media_count );
     3351    }
     3352
     3353    private function reset_omit_loading_attr_filter() {
     3354        // Add filter to "reset" omit threshold back to null (unset).
     3355        add_filter( 'wp_omit_loading_attr_threshold', '__return_null', 100 );
     3356
     3357        // Force filter application to re-run.
     3358        wp_omit_loading_attr_threshold( true );
     3359
     3360        // Clean up the above filter.
     3361        remove_filter( 'wp_omit_loading_attr_threshold', '__return_null', 100 );
     3362    }
    31893363}
    31903364
Note: See TracChangeset for help on using the changeset viewer.