diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 6e7f66760f..dd46e4c141 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -1046,7 +1046,7 @@ function wp_get_attachment_image( $attachment_id, $size = 'thumbnail', $icon = f // Add `loading` attribute. if ( wp_lazy_loading_enabled( 'img', 'wp_get_attachment_image' ) ) { - $default_attr['loading'] = 'lazy'; + $default_attr['loading'] = wp_get_loading_attr_default( 'wp_get_attachment_image' ); } $attr = wp_parse_args( $attr, $default_attr ); @@ -1820,39 +1820,45 @@ function wp_filter_content_tags( $content, $context = null ) { _prime_post_caches( $attachment_ids, false, true ); } - foreach ( $images as $image => $attachment_id ) { - $filtered_image = $image; + // Iterate through the matches in order of occurrence as it is relevant for whether or not to lazy-load. + foreach ( $matches as $match ) { + // Filter an image match. + if ( isset( $images[ $match[0] ] ) ) { + $filtered_image = $match[0]; + $attachment_id = $images[ $match[0] ]; - // Add 'width' and 'height' attributes if applicable. - if ( $attachment_id > 0 && false === strpos( $filtered_image, ' width=' ) && false === strpos( $filtered_image, ' height=' ) ) { - $filtered_image = wp_img_tag_add_width_and_height_attr( $filtered_image, $context, $attachment_id ); + // Add 'width' and 'height' attributes if applicable. + if ( $attachment_id > 0 && false === strpos( $filtered_image, ' width=' ) && false === strpos( $filtered_image, ' height=' ) ) { + $filtered_image = wp_img_tag_add_width_and_height_attr( $filtered_image, $context, $attachment_id ); + } + + // Add 'srcset' and 'sizes' attributes if applicable. + if ( $attachment_id > 0 && false === strpos( $filtered_image, ' srcset=' ) ) { + $filtered_image = wp_img_tag_add_srcset_and_sizes_attr( $filtered_image, $context, $attachment_id ); + } + + // Add 'loading' attribute if applicable. + if ( $add_img_loading_attr && false === strpos( $filtered_image, ' loading=' ) ) { + $filtered_image = wp_img_tag_add_loading_attr( $filtered_image, $context ); + } + + if ( $filtered_image !== $match[0] ) { + $content = str_replace( $match[0], $filtered_image, $content ); + } } - // Add 'srcset' and 'sizes' attributes if applicable. - if ( $attachment_id > 0 && false === strpos( $filtered_image, ' srcset=' ) ) { - $filtered_image = wp_img_tag_add_srcset_and_sizes_attr( $filtered_image, $context, $attachment_id ); - } + // Filter an iframe match. + if ( isset( $iframes[ $match[0] ] ) ) { + $filtered_iframe = $match[0]; - // Add 'loading' attribute if applicable. - if ( $add_img_loading_attr && false === strpos( $filtered_image, ' loading=' ) ) { - $filtered_image = wp_img_tag_add_loading_attr( $filtered_image, $context ); - } + // Add 'loading' attribute if applicable. + if ( $add_iframe_loading_attr && false === strpos( $filtered_iframe, ' loading=' ) ) { + $filtered_iframe = wp_iframe_tag_add_loading_attr( $filtered_iframe, $context ); + } - if ( $filtered_image !== $image ) { - $content = str_replace( $image, $filtered_image, $content ); - } - } - - foreach ( $iframes as $iframe => $attachment_id ) { - $filtered_iframe = $iframe; - - // Add 'loading' attribute if applicable. - if ( $add_iframe_loading_attr && false === strpos( $filtered_iframe, ' loading=' ) ) { - $filtered_iframe = wp_iframe_tag_add_loading_attr( $filtered_iframe, $context ); - } - - if ( $filtered_iframe !== $iframe ) { - $content = str_replace( $iframe, $filtered_iframe, $content ); + if ( $filtered_iframe !== $match[0] ) { + $content = str_replace( $match[0], $filtered_iframe, $content ); + } } } @@ -1869,6 +1875,10 @@ function wp_filter_content_tags( $content, $context = null ) { * @return string Converted `img` tag with `loading` attribute added. */ function wp_img_tag_add_loading_attr( $image, $context ) { + // Get loading attribute value to use. This must occur before the conditional check below so that even images that + // are ineligible for being lazy-loaded are considered. + $value = wp_get_loading_attr_default( $context ); + // Images should have source and dimension attributes for the `loading` attribute to be added. if ( false === strpos( $image, ' src="' ) || false === strpos( $image, ' width="' ) || false === strpos( $image, ' height="' ) ) { return $image; @@ -1883,11 +1893,11 @@ function wp_img_tag_add_loading_attr( $image, $context ) { * @since 5.5.0 * * @param string|bool $value The `loading` attribute value. Returning a falsey value will result in - * the attribute being omitted for the image. Default 'lazy'. + * the attribute being omitted for the image. * @param string $image The HTML `img` tag to be filtered. * @param string $context Additional context about how the function was called or where the img tag is. */ - $value = apply_filters( 'wp_img_tag_add_loading_attr', 'lazy', $image, $context ); + $value = apply_filters( 'wp_img_tag_add_loading_attr', $value, $image, $context ); if ( $value ) { if ( ! in_array( $value, array( 'lazy', 'eager' ), true ) ) { @@ -1995,6 +2005,10 @@ function wp_iframe_tag_add_loading_attr( $iframe, $context ) { return $iframe; } + // Get loading attribute value to use. This must occur before the conditional check below so that even iframes that + // are ineligible for being lazy-loaded are considered. + $value = wp_get_loading_attr_default( $context ); + // Iframes should have source and dimension attributes for the `loading` attribute to be added. if ( false === strpos( $iframe, ' src="' ) || false === strpos( $iframe, ' width="' ) || false === strpos( $iframe, ' height="' ) ) { return $iframe; @@ -2009,11 +2023,11 @@ function wp_iframe_tag_add_loading_attr( $iframe, $context ) { * @since 5.7.0 * * @param string|bool $value The `loading` attribute value. Returning a falsey value will result in - * the attribute being omitted for the iframe. Default 'lazy'. + * the attribute being omitted for the iframe. * @param string $iframe The HTML `iframe` tag to be filtered. * @param string $context Additional context about how the function was called or where the iframe tag is. */ - $value = apply_filters( 'wp_iframe_tag_add_loading_attr', 'lazy', $iframe, $context ); + $value = apply_filters( 'wp_iframe_tag_add_loading_attr', $value, $iframe, $context ); if ( $value ) { if ( ! in_array( $value, array( 'lazy', 'eager' ), true ) ) { @@ -5177,3 +5191,97 @@ function wp_get_webp_info( $filename ) { return compact( 'width', 'height', 'type' ); } + +/** + * Gets the default value to use for a `loading` attribute on an element. + * + * This function should only be called for a tag and context if lazy-loading is generally enabled. + * + * The function usually returns 'lazy', but uses certain heuristics to guess whether the current element is likely to + * appear above the fold, in which case it returns a boolean `false`, which will lead to the `loading` attribute being + * omitted on the element. The purpose of this refinement is to avoid lazy-loading elements that are within the initial + * viewport, which can have a negative performance impact. + * + * Under the hood, the function uses {@see wp_increase_content_media_count()} every time it is called for an element + * within the main content. If the element is the very first content element, the `loading` attribute will be omitted. + * This default threshold of 1 content element to omit the `loading` attribute for can be customized using the + * {@see 'wp_omit_loading_attr_threshold'} filter. + * + * @since 5.9.0 + * + * @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 ) { + // Only elements with 'the_content' or 'the_post_thumbnail' context have special handling. + if ( 'the_content' !== $context && 'the_post_thumbnail' !== $context ) { + return 'lazy'; + } + + // Only elements within the main query loop have special handling. + if ( is_admin() || ! in_the_loop() || ! is_main_query() ) { + return 'lazy'; + } + + // Increase the counter since this is a main query content element. + $content_media_count = wp_increase_content_media_count(); + + // If the count so far is below the threshold, return `false` so that the `loading` attribute is omitted. + if ( $content_media_count <= wp_omit_loading_attr_threshold() ) { + return false; + } + + // For elements after the threshold, lazy-load them as usual. + return 'lazy'; +} + +/** + * Gets the threshold for how many of the first content media elements to not lazy-load. + * + * This function runs the {@see 'wp_omit_loading_attr_threshold'} filter, which uses a default threshold value of 1. + * The filter is only run once per page load, unless the `$force` parameter is used. + * + * @since 5.9.0 + * + * @param bool $force Optional. If set to true, the filter will be (re-)applied even if it already has been before. + * Default false. + * @return int The number of content media elements to not lazy-load. + */ +function wp_omit_loading_attr_threshold( $force = false ) { + static $omit_threshold; + + // This function may be called multiple times. Run the filter only once per page load. + if ( ! isset( $omit_threshold ) || $force ) { + /** + * Filters the threshold for how many of the first content media elements to not lazy-load. + * + * For these first content media elements, the `loading` attribute will be omitted. By default, this is the case + * for only the very first content media element. + * + * @since 5.9.0 + * + * @param int $omit_threshold The number of media elements where the `loading` attribute will not be added. Default 1. + */ + $omit_threshold = apply_filters( 'wp_omit_loading_attr_threshold', 1 ); + } + + return $omit_threshold; +} + +/** + * Increases an internal content media count variable. + * + * @since 5.9.0 + * @access private + * + * @param int $amount Optional. Amount to increase by. Default 1. + * @return int The latest content media count, after the increase. + */ +function wp_increase_content_media_count( $amount = 1 ) { + static $content_media_count = 0; + + $content_media_count += $amount; + + return $content_media_count; +} diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index 4a5891b8fd..e30fad1808 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -2678,7 +2678,7 @@ if ( ! function_exists( 'get_avatar' ) ) : ); if ( wp_lazy_loading_enabled( 'img', 'get_avatar' ) ) { - $defaults['loading'] = 'lazy'; + $defaults['loading'] = wp_get_loading_attr_default( 'get_avatar' ); } if ( empty( $args ) ) { diff --git a/src/wp-includes/post-thumbnail-template.php b/src/wp-includes/post-thumbnail-template.php index 79b9a55d3a..9e570191b9 100644 --- a/src/wp-includes/post-thumbnail-template.php +++ b/src/wp-includes/post-thumbnail-template.php @@ -186,6 +186,19 @@ function get_the_post_thumbnail( $post = null, $size = 'post-thumbnail', $attr = update_post_thumbnail_cache(); } + // Get the 'loading' attribute value to use as default, taking precedence over the default from + // `wp_get_attachment_image()`. + $loading = wp_get_loading_attr_default( 'the_post_thumbnail' ); + + // Add the default to the given attributes unless they already include a 'loading' directive. + if ( empty( $attr ) ) { + $attr = array( 'loading' => $loading ); + } elseif ( is_array( $attr ) && ! array_key_exists( 'loading', $attr ) ) { + $attr['loading'] = $loading; + } elseif ( is_string( $attr ) && ! preg_match( '/(^|&)loading=', $attr ) ) { + $attr .= '&loading=' . $loading; + } + $html = wp_get_attachment_image( $post_thumbnail_id, $size, false, $attr ); /** diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index 0483f0c084..c338a2baf7 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -3024,6 +3024,7 @@ EOF; /** * @ticket 50425 * @ticket 53463 + * @ticket 53675 * @dataProvider data_wp_lazy_loading_enabled_context_defaults * * @param string $context Function context. @@ -3046,6 +3047,7 @@ EOF; 'widget_block_content => true' => array( 'widget_block_content', true ), 'get_avatar => true' => array( 'get_avatar', true ), 'arbitrary context => true' => array( 'something_completely_arbitrary', true ), + 'the_post_thumbnail => true' => array( 'the_post_thumbnail', true ), ); } @@ -3186,6 +3188,178 @@ EOF; array( 'trash-attachment', '/?attachment_id=%ID%', false ), ); } + + /** + * @ticket 53675 + * @dataProvider data_wp_get_loading_attr_default + * + * @param string $context + */ + 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' ) ); + + // 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'] ) ) ); + $this->reset_content_media_count(); + $this->reset_omit_loading_attr_filter(); + + while ( have_posts() ) { + the_post(); + + // Return 'lazy' if in the loop but not in the main query. + $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) ); + + // Set as main query. + $wp_the_query = $wp_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. + $this->assertSame( 'lazy', wp_get_loading_attr_default( 'wp_get_attachment_image' ) ); + + // Return `false` if in the loop and in the main query and it is the first element. + $this->assertFalse( wp_get_loading_attr_default( $context ) ); + + // Return 'lazy' if in the loop and in the main query for any subsequent elements. + $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) ); + + // Yes, for all subsequent elements. + $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) ); + } + } + + function data_wp_get_loading_attr_default() { + return array( + array( 'the_content' ), + array( 'the_post_thumbnail' ), + ); + } + + /** + * @ticket 53675 + */ + 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; + $this->reset_content_media_count(); + $this->reset_omit_loading_attr_filter(); + + // Use the filter to alter the threshold for not lazy-loading to the first three elements. + add_filter( + 'wp_omit_loading_attr_threshold', + function() { + return 3; + } + ); + + while ( have_posts() ) { + the_post(); + + // Due to the filter, now the first three elements should not be lazy-loaded, i.e. return `false`. + for ( $i = 0; $i < 3; $i++ ) { + $this->assertFalse( wp_get_loading_attr_default( 'the_content' ) ); + } + + // For following elements, lazy-load them again. + $this->assertSame( 'lazy', wp_get_loading_attr_default( 'the_content' ) ); + } + } + + /** + * @ticket 53675 + */ + 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 = ''; + $img2 = get_image_tag( self::$large_id, '', '', '', 'medium' ); + $img3 = get_image_tag( self::$large_id, '', '', '', 'thumbnail' ); + $iframe2 = ''; + $lazy_img2 = wp_img_tag_add_loading_attr( $img2, 'the_content' ); + $lazy_img3 = wp_img_tag_add_loading_attr( $img3, 'the_content' ); + $lazy_iframe2 = wp_iframe_tag_add_loading_attr( $iframe2, 'the_content' ); + + // Use a threshold of 2. + add_filter( + 'wp_omit_loading_attr_threshold', + function() { + return 2; + } + ); + + // Following the threshold of 2, the first two content media elements should not be lazy-loaded. + $content_unfiltered = $img1 . $iframe1 . $img2 . $img3 . $iframe2; + $content_expected = $img1 . $iframe1 . $lazy_img2 . $lazy_img3 . $lazy_iframe2; + + $wp_query = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) ); + $wp_the_query = $wp_query; + $this->reset_content_media_count(); + $this->reset_omit_loading_attr_filter(); + + while ( have_posts() ) { + the_post(); + + add_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' ); + $content_filtered = wp_filter_content_tags( $content_unfiltered, 'the_content' ); + remove_filter( 'wp_img_tag_add_srcset_and_sizes_attr', '__return_false' ); + } + + // After filtering, the first image should not be lazy-loaded while the other ones should be. + $this->assertSame( $content_expected, $content_filtered ); + } + + /** + * @ticket 53675 + */ + public function test_wp_omit_loading_attr_threshold() { + $this->reset_omit_loading_attr_filter(); + + // Apply filter, ensure default value of 1. + $omit_threshold = wp_omit_loading_attr_threshold(); + $this->assertSame( 1, $omit_threshold ); + + // Add a filter that changes the value to 3. However, the filter is not applied a subsequent time in a single + // page load by default, so the value is still 1. + add_filter( + 'wp_omit_loading_attr_threshold', + function() { + return 3; + } + ); + $omit_threshold = wp_omit_loading_attr_threshold(); + $this->assertSame( 1, $omit_threshold ); + + // Only by enforcing a fresh check, the filter gets re-applied. + $omit_threshold = wp_omit_loading_attr_threshold( true ); + $this->assertSame( 3, $omit_threshold ); + } + + private function reset_content_media_count() { + // Get current value without increasing. + $content_media_count = wp_increase_content_media_count( 0 ); + + // Decrease it by its current value to "reset" it back to 0. + wp_increase_content_media_count( - $content_media_count ); + } + + private function reset_omit_loading_attr_filter() { + // Add filter to "reset" omit threshold back to null (unset). + add_filter( 'wp_omit_loading_attr_threshold', '__return_null', 100 ); + + // Force filter application to re-run. + wp_omit_loading_attr_threshold( true ); + + // Clean up the above filter. + remove_filter( 'wp_omit_loading_attr_threshold', '__return_null', 100 ); + } } /**