From 8a12a494958d84a8d08b9d3e48069f8603383942 Mon Sep 17 00:00:00 2001 From: Matt Robinson Date: Sun, 12 Nov 2023 12:15:05 +0000 Subject: [PATCH] WP-r55850: Media: Fix bug in lazy-loading of images in excerpts (#274) * WP-r55816: Media: Increase default for `wp_omit_loading_attr_threshold` to 3. The previous default threshold for how many content images to skip lazy-loading on (which was just 1) has proven to be too strict: HTTP Archive data shows that >70% of sites have up to 3 equal-sized images in the initial viewport, each of which could be the LCP image and therefore should not be lazy-loaded. Lazy-loading too many images has adverse effects on load time performance, while increasing the default threshold will not negatively affect load time performance even for sites where a threshold of 1 would be the perfect choice. The change of default value in this changeset will improve performance for more WordPress sites out of the box. The `wp_omit_loading_attr_threshold` filter can still be used to customize and fine tune the value where needed. WP:Props thekt12, spacedmonkey, westonruter, flixos90. Fixes https://core.trac.wordpress.org/ticket/58213. Conflicts: - tests/phpunit/tests/media.php --- Merges https://core.trac.wordpress.org/changeset/55816 / WordPress/wordpress-develop@a56a83fc6c to ClassicPress. * Fix merge conflicts from 55816 * WP-r55847: 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 https://core.trac.wordpress.org/changeset/55318, however this enhancement implements the solution in a more generally applicable way that brings the improvement to classic themes as well. WP:Props thekt12, flixos90, spacedmonkey, costdev, zunaid321, mukesh27. Fixes https://core.trac.wordpress.org/ticket/58211. See https://core.trac.wordpress.org/ticket/53675, https://core.trac.wordpress.org/ticket/56930. Conflicts: - src/wp-includes/media.php - tests/phpunit/tests/media.php --- Merges https://core.trac.wordpress.org/changeset/55847 / WordPress/wordpress-develop@71140f327f to ClassicPress. * Fix merge conflicts from 55847 * WP-r55850: Media: Fix lazy-loading bug by avoiding to modify content images when creating an excerpt. The `wp_filter_content_tags()` function, which modifies image tags for example to optimize performance, is hooked into the `the_content` filter by default. When rendering an excerpt for a post that doesn't have a manually provided excerpt, the post content is used to generate the excerpt, handled by the `wp_trim_excerpt()` function. Prior to this changeset, this led to `wp_filter_content_tags()` being called on the content when generating the excerpt, which is wasteful as all tags are stripped from the excerpt, and it furthermore could result in a lazy-loading bug when the post content contained images, as those images were being counted even though they would never be rendered as part of the excerpt. This changeset fixes the bug and slightly improves performance for generating an excerpt by temporarily unhooking the `wp_filter_content_tags()` function from the `the_content` filter when using it to generate the excerpt. WP:Props costdev, flixos90, joemcgill, mukesh27, salvoaranzulla, spacedmonkey, thekt12, westonruter. Fixes https://core.trac.wordpress.org/ticket/56588. Conflicts: - tests/phpunit/tests/media.php --- Merges https://core.trac.wordpress.org/changeset/55850 / WordPress/wordpress-develop@6ff355e87d to ClassicPress. * Fix merge conflicts in 55850 * WP-r55825: Media: Prevent special images within post content to skew image counts and cause lazy-loading bugs. In order to skip lazy-loading the first few images on a page, as of WordPress 5.9 there has been logic to count images that are eligible based on certain criteria. One of those groups are images that appear within the content of a post. This changeset fixes a bug where images created via `get_the_post_thumbnail()` or `wp_get_attachment_image()` that are injected into the post content would skew the count and therefore result in all images to be lazy-loaded, potentially hurting load time performance. This is relevant for example when those functions are called in server-side rendered blocks, or any other filter callbacks hooked into `the_content`. WP:Props flixos90, antpb, joedolson, spacedmonkey, mukesh27, thekt12, costdev, jrf. Fixes https://core.trac.wordpress.org/ticket/58089. See https://core.trac.wordpress.org/ticket/53675. --- Merges https://core.trac.wordpress.org/changeset/55825 / WordPress/wordpress-develop@23b007b126 to ClassicPress. * Removed duplicated test from 55825 --------- Co-authored-by: Felix Arntz --- src/wp-includes/class-wp-query.php | 12 + src/wp-includes/formatting.php | 16 + src/wp-includes/media.php | 50 +- .../tests/formatting/WpTrimExcerpt.php | 56 +++ tests/phpunit/tests/media.php | 432 ++++++++++++++++-- tests/phpunit/tests/query.php | 67 +++ 6 files changed, 583 insertions(+), 50 deletions(-) diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index 9f56a2acc4..ce9fcb8233 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -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 @@ public function init() { $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 @@ public function the_post() { } $this->in_the_loop = true; + $this->before_loop = false; if ( -1 == $this->current_post ) { // Loop has just started. /** @@ -3671,6 +3681,8 @@ public function have_posts() { // 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. * diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 5db54014d6..4befdf523c 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -3923,10 +3923,26 @@ function wp_trim_excerpt( $text = '', $post = null ) { $text = strip_shortcodes( $text ); + /* + * Temporarily unhook wp_filter_content_tags() since any tags + * within the excerpt are stripped out. Modifying the tags here + * is wasteful and can lead to bugs in the image counting logic. + */ + $filter_removed = remove_filter( 'the_content', 'wp_filter_content_tags' ); + /** This filter is documented in wp-includes/post-template.php */ $text = apply_filters( 'the_content', $text ); $text = str_replace( ']]>', ']]>', $text ); + /** + * Only restore the filter callback if it was removed above. The logic + * to unhook and restore only applies on the default priority of 10, + * which is generally used for the filter callback in WordPress core. + */ + if ( $filter_removed ) { + add_filter( 'the_content', 'wp_filter_content_tags' ); + } + /* translators: Maximum number of words used in a post excerpt. */ $excerpt_length = (int) _x( '55', 'excerpt_length' ); diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 5626c63ad3..0de8cf82d5 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5336,16 +5336,20 @@ function wp_get_webp_info( $filename ) { * * 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 + * This default threshold of 3 content elements to omit the `loading` attribute for can be customized using the * {@see 'wp_omit_loading_attr_threshold'} filter. * * @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; @@ -5357,6 +5361,43 @@ function wp_get_loading_attr_default( $context ) { 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; + } + } + + /* + * 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; + } + /* * The first elements in 'the_content' or 'the_post_thumbnail' should not be lazy-loaded, * as they are likely above the fold. @@ -5386,7 +5427,7 @@ function wp_get_loading_attr_default( $context ) { /** * 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. + * This function runs the {@see 'wp_omit_loading_attr_threshold'} filter, which uses a default threshold value of 3. * The filter is only run once per page load, unless the `$force` parameter is used. * * @since 5.9.0 @@ -5407,10 +5448,11 @@ function wp_omit_loading_attr_threshold( $force = false ) { * for only the very first content media element. * * @since 5.9.0 + * @since 6.3.0 The default threshold was changed from 1 to 3. * - * @param int $omit_threshold The number of media elements where the `loading` attribute will not be added. Default 1. + * @param int $omit_threshold The number of media elements where the `loading` attribute will not be added. Default 3. */ - $omit_threshold = apply_filters( 'wp_omit_loading_attr_threshold', 1 ); + $omit_threshold = apply_filters( 'wp_omit_loading_attr_threshold', 3 ); } return $omit_threshold; diff --git a/tests/phpunit/tests/formatting/WpTrimExcerpt.php b/tests/phpunit/tests/formatting/WpTrimExcerpt.php index c3ab336bf9..ba5e99f7d8 100644 --- a/tests/phpunit/tests/formatting/WpTrimExcerpt.php +++ b/tests/phpunit/tests/formatting/WpTrimExcerpt.php @@ -92,4 +92,60 @@ public function test_should_generate_excerpt_for_empty_values() { $this->assertSame( 'Post content', wp_trim_excerpt( null, $post ) ); $this->assertSame( 'Post content', wp_trim_excerpt( false, $post ) ); } + + /** + * Tests that `wp_trim_excerpt()` unhooks `wp_filter_content_tags()` from 'the_content' filter. + * + * @ticket 56588 + */ + public function test_wp_trim_excerpt_unhooks_wp_filter_content_tags() { + $post = self::factory()->post->create(); + + /* + * Record that during 'the_content' filter run by wp_trim_excerpt() the + * wp_filter_content_tags() callback is not used. + */ + $has_filter = true; + add_filter( + 'the_content', + static function ( $content ) use ( &$has_filter ) { + $has_filter = has_filter( 'the_content', 'wp_filter_content_tags' ); + return $content; + } + ); + + wp_trim_excerpt( '', $post ); + + $this->assertFalse( $has_filter, 'wp_filter_content_tags() was not unhooked in wp_trim_excerpt()' ); + } + + /** + * Tests that `wp_trim_excerpt()` doesn't permanently unhook `wp_filter_content_tags()` from 'the_content' filter. + * + * @ticket 56588 + */ + public function test_wp_trim_excerpt_should_not_permanently_unhook_wp_filter_content_tags() { + $post = self::factory()->post->create(); + + wp_trim_excerpt( '', $post ); + + $this->assertSame( 10, has_filter( 'the_content', 'wp_filter_content_tags' ), 'wp_filter_content_tags() was not restored in wp_trim_excerpt()' ); + } + + /** + * Tests that `wp_trim_excerpt()` doesn't restore `wp_filter_content_tags()` if it was previously unhooked. + * + * @ticket 56588 + */ + public function test_wp_trim_excerpt_does_not_restore_wp_filter_content_tags_if_previously_unhooked() { + $post = self::factory()->post->create(); + + // Remove wp_filter_content_tags() from 'the_content' filter generally. + remove_filter( 'the_content', 'wp_filter_content_tags' ); + + wp_trim_excerpt( '', $post ); + + // Assert that the filter callback was not restored after running 'the_content'. + $this->assertFalse( has_filter( 'the_content', 'wp_filter_content_tags' ) ); + } } diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index 0335e45d0e..e3273bf9de 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -75,6 +75,16 @@ public static function tear_down_after_class() { parent::tear_down_after_class(); } + /** + * Ensures that the static content media count and related filter are reset between tests. + */ + public function set_up() { + parent::set_up(); + + $this->reset_content_media_count(); + $this->reset_omit_loading_attr_filter(); + } + public function test_img_caption_shortcode_added() { global $shortcode_tags; $this->assertSame( 'img_caption_shortcode', $shortcode_tags['caption'] ); @@ -3349,8 +3359,6 @@ public function data_attachment_permalinks_based_on_parent_status() { * @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' ) ); @@ -3358,9 +3366,7 @@ public function test_wp_get_loading_attr_default( $context ) { // 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(); + $query = $this->get_new_wp_query_for_published_post(); while ( have_posts() ) { the_post(); @@ -3369,14 +3375,16 @@ public function test_wp_get_loading_attr_default( $context ) { $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. $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 `false` in the main query for first three element. + $this->assertFalse( wp_get_loading_attr_default( $context ), 'Expected first image to not be lazy-loaded.' ); + $this->assertFalse( wp_get_loading_attr_default( $context ), 'Expected second image to not be lazy-loaded.' ); + $this->assertFalse( wp_get_loading_attr_default( $context ), 'Expected third image to not be lazy-loaded.' ); // Return 'lazy' if in the loop and in the main query for any subsequent elements. $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) ); @@ -3397,26 +3405,17 @@ public function data_wp_get_loading_attr_default() { * @ticket 53675 */ public function test_wp_omit_loading_attr_threshold_filter() { - global $wp_query, $wp_the_query; + $query = $this->get_new_wp_query_for_published_post(); + $this->set_main_query( $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; - } - ); + // Use the filter to alter the threshold for not lazy-loading to the first five elements. + $this->force_omit_loading_attr_threshold( 5 ); 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++ ) { + // Due to the filter, now the first five elements should not be lazy-loaded, i.e. return `false`. + for ( $i = 0; $i < 5; $i++ ) { $this->assertFalse( wp_get_loading_attr_default( 'the_content' ) ); } @@ -3429,8 +3428,6 @@ function () { * @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 = ''; $img2 = get_image_tag( self::$large_id, '', '', '', 'medium' ); @@ -3441,22 +3438,15 @@ public function test_wp_filter_content_tags_with_wp_get_loading_attr_default() { $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; - } - ); + $this->force_omit_loading_attr_threshold( 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; $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; - $this->reset_content_media_count(); - $this->reset_omit_loading_attr_filter(); + $query = $this->get_new_wp_query_for_published_post(); + $this->set_main_query( $query ); while ( have_posts() ) { the_post(); @@ -3476,24 +3466,332 @@ function () { public function test_wp_omit_loading_attr_threshold() { $this->reset_omit_loading_attr_filter(); - // Apply filter, ensure default value of 1. + // Apply filter, ensure default value of 3. + $omit_threshold = wp_omit_loading_attr_threshold(); + $this->assertSame( 3, $omit_threshold ); + + // Add a filter that changes the value to 1. However, the filter is not applied a subsequent time in a single + // page load by default, so the value is still 3. + $this->force_omit_loading_attr_threshold( 1 ); + $omit_threshold = wp_omit_loading_attr_threshold(); + $this->assertSame( 3, $omit_threshold ); + + // Only by enforcing a fresh check, the filter gets re-applied. + $omit_threshold = wp_omit_loading_attr_threshold( true ); $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(); + + do_action( 'get_header' ); + + // Lazy if not main query. + $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) ); + } - // 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. + /** + * 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 ); + + // 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 ); + + 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 ); + + 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 ); + + // 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' ), + ); + } + + /** + * @ticket 58089 + * + * @covers ::wp_filter_content_tags + * @covers ::wp_get_loading_attr_default + */ + public function test_wp_filter_content_tags_does_not_lazy_load_special_images_within_the_content() { + global $wp_query, $wp_the_query; + + // Force no lazy-loading on the image tag expected in the content. + $expected_content = wpautop( wp_get_attachment_image( self::$large_id, 'large', false, array( 'loading' => false ) ) ); + + // Overwrite post content with an image. add_filter( - 'wp_omit_loading_attr_threshold', - function () { - return 3; + 'the_content', + static function () { + // Replace content with an image tag, i.e. the 'wp_get_attachment_image' context is used while running 'the_content' filter. + return wp_get_attachment_image( self::$large_id, 'large', false ); + }, + 9 // Run before wp_filter_content_tags(). + ); + + /* + * We have to run a main query loop so that the first 'the_content' context image is not + * lazy-loaded. + * Without the fix from 58089, the image would still be lazy-loaded since the check for the + * separately invoked 'wp_get_attachment_image' context would lead to that. + */ + $wp_query = new WP_Query( array( 'post__in' => array( self::$post_ids['publish'] ) ) ); + $wp_the_query = $wp_query; + + $content = ''; + while ( have_posts() ) { + the_post(); + $content = get_echo( 'the_content' ); + } + + // Ensure that parsed content has the image without lazy-loading. + $this->assertSame( $expected_content, $content ); + } + + /** + * Tests that wp_get_loading_attr_default() returns 'lazy' for special contexts when they're used outside of 'the_content' filter. + * + * @ticket 58089 + * + * @covers ::wp_get_loading_attr_default + * + * @dataProvider data_special_contexts_for_the_content + * + * @param string $context Context for the element for which the `loading` attribute value is requested. + */ + public function test_wp_get_loading_attr_default_should_return_lazy_for_special_contexts_outside_of_the_content( $context ) { + $this->assertSame( 'lazy', wp_get_loading_attr_default( $context ) ); + } + + /** + * Tests that wp_get_loading_attr_default() returns false for special contexts when they're used within 'the_content' filter. + * + * @ticket 58089 + * + * @covers ::wp_get_loading_attr_default + * + * @dataProvider data_special_contexts_for_the_content + * + * @param string $context Context for the element for which the `loading` attribute value is requested. + */ + public function test_wp_get_loading_attr_default_should_return_false_for_special_contexts_within_the_content( $context ) { + remove_all_filters( 'the_content' ); + + $result = null; + add_filter( + 'the_content', + function ( $content ) use ( &$result, $context ) { + $result = wp_get_loading_attr_default( $context ); + return $content; } ); - $omit_threshold = wp_omit_loading_attr_threshold(); - $this->assertSame( 1, $omit_threshold ); + apply_filters( 'the_content', '' ); + $this->assertFalse( $result ); + } - // Only by enforcing a fresh check, the filter gets re-applied. - $omit_threshold = wp_omit_loading_attr_threshold( true ); - $this->assertSame( 3, $omit_threshold ); + /** + * Data provider. + * + * @return array[] + */ + public function data_special_contexts_for_the_content() { + return array( + 'the_post_thumbnail' => array( 'context' => 'the_post_thumbnail' ), + 'wp_get_attachment_image' => array( 'context' => 'wp_get_attachment_image' ), + ); + } + + /** + * Tests that the content media count is not affected by `the_excerpt()` calls for posts that contain images. + * + * @ticket 56588 + * + * @covers ::wp_trim_excerpt + */ + public function test_the_excerpt_does_not_affect_content_media_count() { + global $wp_query, $wp_the_query; + + /* + * Use the filter to alter the threshold for not lazy-loading to the first 2 elements, + * then use a post that contains exactly 2 images. + */ + $this->force_omit_loading_attr_threshold( 2 ); + $post_content = ''; + $post_content .= '

Some text.

'; + $post_content .= ''; + + $post_id = self::factory()->post->create( + array( + 'post_content' => $post_content, + 'post_excerpt' => '', + ) + ); + + $wp_query = new WP_Query( array( 'post__in' => array( $post_id ) ) ); + $wp_the_query = $wp_query; + + while ( have_posts() ) { + the_post(); + + // Call `the_excerpt()` without generating output. + get_echo( 'the_excerpt' ); + } + + // The only way to access the value is by calling this function without increasing the value. + $content_media_count = wp_increase_content_media_count( 0 ); + + // Assert that the media count was not increased even though there are 3 images in the post's content. + $this->assertSame( 0, $content_media_count ); + } + + /** + * Tests that the lazy-loading result is not affected by `the_excerpt()` calls for posts that + * contain images. + * + * Printing the excerpt for a post that contains images in its content prior to its featured image should result in + * that featured image not being lazy-loaded, since the images in the post content aren't displayed in the excerpt. + * + * @ticket 56588 + * + * @covers ::wp_trim_excerpt + */ + public function test_the_excerpt_does_not_affect_omit_lazy_loading_logic() { + global $wp_query, $wp_the_query; + + /* + * Use the filter to alter the threshold for not lazy-loading to the first 2 elements, + * then use a post that contains exactly 2 images. + */ + $this->force_omit_loading_attr_threshold( 2 ); + $post_content = ''; + $post_content .= '

Some text.

'; + $post_content .= ''; + + $post_id = self::factory()->post->create( + array( + 'post_content' => $post_content, + 'post_excerpt' => '', + ) + ); + $featured_image_id = self::$large_id; + update_post_meta( $post_id, '_thumbnail_id', $featured_image_id ); + + $expected_image_tag = get_the_post_thumbnail( $post_id, 'post-thumbnail', array( 'loading' => false ) ); + + $wp_query = new WP_Query( array( 'post__in' => array( $post_id ) ) ); + $wp_the_query = $wp_query; + + $output = ''; + while ( have_posts() ) { + the_post(); + + // Print excerpt first, then the featured image. + $output .= get_echo( 'the_excerpt' ); + $output .= get_echo( 'the_post_thumbnail' ); + } + + $this->assertStringContainsString( $expected_image_tag, $output ); } private function reset_content_media_count() { @@ -3644,6 +3942,48 @@ public function image_editor_change_quality_low_jpeg( $quality, $mime_type ) { return 30; } } + + /** + * Change the omit loading attribute threshold value. + * + * @param int $threshold Threshold value to change. + */ + public function force_omit_loading_attr_threshold( $threshold ) { + add_filter( + 'wp_omit_loading_attr_threshold', + static function () use ( $threshold ) { + return $threshold; + } + ); + } + + /** + * 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; + } } /** diff --git a/tests/phpunit/tests/query.php b/tests/phpunit/tests/query.php index f04139c085..37dd51a063 100644 --- a/tests/phpunit/tests/query.php +++ b/tests/phpunit/tests/query.php @@ -897,4 +897,71 @@ public function test_query_tag_404_does_not_throw_warning() { $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; + } }