diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 1a20c919d2..adba75fcd7 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -195,16 +195,17 @@ add_filter( 'the_content', 'convert_smilies', 20 ); add_filter( 'the_content', 'wpautop' ); add_filter( 'the_content', 'shortcode_unautop' ); add_filter( 'the_content', 'prepend_attachment' ); -add_filter( 'the_content', 'wp_filter_content_tags' ); add_filter( 'the_content', 'wp_replace_insecure_home_url' ); +add_filter( 'the_content', 'do_shortcode', 11 ); // AFTER wpautop(). +add_filter( 'the_content', 'wp_filter_content_tags', 12 ); // Runs after do_shortcode(). add_filter( 'the_excerpt', 'wptexturize' ); add_filter( 'the_excerpt', 'convert_smilies' ); add_filter( 'the_excerpt', 'convert_chars' ); add_filter( 'the_excerpt', 'wpautop' ); add_filter( 'the_excerpt', 'shortcode_unautop' ); -add_filter( 'the_excerpt', 'wp_filter_content_tags' ); add_filter( 'the_excerpt', 'wp_replace_insecure_home_url' ); +add_filter( 'the_excerpt', 'wp_filter_content_tags', 12 ); add_filter( 'get_the_excerpt', 'wp_trim_excerpt', 10, 2 ); add_filter( 'the_post_thumbnail_caption', 'wptexturize' ); @@ -230,13 +231,13 @@ add_filter( 'widget_text_content', 'wptexturize' ); add_filter( 'widget_text_content', 'convert_smilies', 20 ); add_filter( 'widget_text_content', 'wpautop' ); add_filter( 'widget_text_content', 'shortcode_unautop' ); -add_filter( 'widget_text_content', 'wp_filter_content_tags' ); add_filter( 'widget_text_content', 'wp_replace_insecure_home_url' ); add_filter( 'widget_text_content', 'do_shortcode', 11 ); // Runs after wpautop(); note that $post global will be null when shortcodes run. +add_filter( 'widget_text_content', 'wp_filter_content_tags', 12 ); // Runs after do_shortcode(). add_filter( 'widget_block_content', 'do_blocks', 9 ); -add_filter( 'widget_block_content', 'wp_filter_content_tags' ); add_filter( 'widget_block_content', 'do_shortcode', 11 ); +add_filter( 'widget_block_content', 'wp_filter_content_tags', 12 ); // Runs after do_shortcode(). add_filter( 'block_type_metadata', 'wp_migrate_old_typography_shape' ); @@ -625,9 +626,6 @@ add_action( 'change_locale', 'create_initial_taxonomies' ); add_action( 'template_redirect', 'redirect_canonical' ); add_action( 'template_redirect', 'wp_redirect_admin_locations', 1000 ); -// Shortcodes. -add_filter( 'the_content', 'do_shortcode', 11 ); // AFTER wpautop(). - // Media. add_action( 'wp_playlist_scripts', 'wp_playlist_scripts' ); add_action( 'customize_controls_enqueue_scripts', 'wp_plupload_default_settings' ); diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 76f77c2a90..f94d3170a5 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -3980,7 +3980,7 @@ function wp_trim_excerpt( $text = '', $post = null ) { * within the excerpt are stripped out. Modifying the tags here * is wasteful and can lead to bugs in the image counting logic. */ - $filter_image_removed = remove_filter( 'the_content', 'wp_filter_content_tags' ); + $filter_image_removed = remove_filter( 'the_content', 'wp_filter_content_tags', 12 ); /* * Temporarily unhook do_blocks() since excerpt_remove_blocks( $text ) @@ -4003,7 +4003,7 @@ function wp_trim_excerpt( $text = '', $post = null ) { * which is generally used for the filter callback in WordPress core. */ if ( $filter_image_removed ) { - add_filter( 'the_content', 'wp_filter_content_tags' ); + add_filter( 'the_content', 'wp_filter_content_tags', 12 ); } /* translators: Maximum number of words used in a post excerpt. */ diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 13fa8e9930..988086e009 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -5649,16 +5649,20 @@ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) { } /* - * Skip programmatically created images within post content as they need to be handled together with the other - * images within the post content. + * Skip programmatically created images within content blobs as they need to be handled together with the other + * images within the post content or widget content. * Without this clause, they would already be considered within their own context which skews the image count and * can result in the first post content image being lazy-loaded or an image further down the page being marked as a * high priority. */ - // TODO: Handle shortcode images together with the content (see https://core.trac.wordpress.org/ticket/58853). - if ( 'the_content' !== $context && 'do_shortcode' !== $context && doing_filter( 'the_content' ) ) { + if ( + 'the_content' !== $context && doing_filter( 'the_content' ) || + 'widget_text_content' !== $context && doing_filter( 'widget_text_content' ) || + 'widget_block_content' !== $context && doing_filter( 'widget_block_content' ) + ) { /** This filter is documented in wp-includes/media.php */ return apply_filters( 'wp_get_loading_optimization_attributes', $loading_attrs, $tag_name, $attr, $context ); + } /* diff --git a/tests/phpunit/tests/formatting/wpTrimExcerpt.php b/tests/phpunit/tests/formatting/wpTrimExcerpt.php index faa67d904a..0f9c6e9cb7 100644 --- a/tests/phpunit/tests/formatting/wpTrimExcerpt.php +++ b/tests/phpunit/tests/formatting/wpTrimExcerpt.php @@ -129,7 +129,7 @@ class Tests_Formatting_wpTrimExcerpt extends WP_UnitTestCase { 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()' ); + $this->assertSame( 12, has_filter( 'the_content', 'wp_filter_content_tags' ), 'wp_filter_content_tags() was not restored in wp_trim_excerpt()' ); } /** @@ -141,7 +141,7 @@ class Tests_Formatting_wpTrimExcerpt extends WP_UnitTestCase { $post = self::factory()->post->create(); // Remove wp_filter_content_tags() from 'the_content' filter generally. - remove_filter( 'the_content', 'wp_filter_content_tags' ); + remove_filter( 'the_content', 'wp_filter_content_tags', 12 ); wp_trim_excerpt( '', $post ); diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index d70b54261a..87b25e8b3d 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -4958,6 +4958,254 @@ EOF; $this->assertStringContainsString( $expected_image_tag, $output ); } + /** + * Tests that wp_filter_content_tags() and more specifically wp_get_loading_optimization_attributes() correctly + * handle shortcodes images together with the content that it is part of. + * + * Images within shortcodes as part of the content should be ignored by wp_get_loading_optimization_attributes() to + * avoid double processing. They should instead only be processed together with any other images as part of the + * content, to correctly count the original sequencing of those images. + * + * @ticket 58853 + * + * @covers ::wp_filter_content_tags + * @covers ::wp_get_loading_optimization_attributes + */ + public function test_wp_filter_content_tags_handles_shortcode_image_together_with_the_content() { + global $wp_query, $wp_the_query; + + // Add shortcode that prints a large image, and a block type that wraps it. + add_shortcode( + 'full_image', + static function ( $atts ) { + $atts = shortcode_atts( + array( + 'id' => 0, + ), + $atts, + 'full_image' + ); + return wp_get_attachment_image( (int) $atts['id'], 'full' ); + } + ); + + /* + * Even though `do_shortcode()` runs before `wp_filter_content_tags()`, the image from the shortcode should not + * receive any loading optimization attributes because it needs to be considered together with the rest of the + * post content, within `wp_filter_content_tags()`. + * Since the hard-coded image appears before the shortcode image, it should receive `fetchpriority="high"`, + * despite the shortcode image being parsed before it. + */ + $post_content = '' . "\n"; + $post_content .= '[full_image id="' . self::$large_id . '"]'; + $post_content = wpautop( $post_content ); + + /* + * Prepare the expected output: + * 1. On the first image (hard-coded in the content), expect `fetchpriority="high"`. + * 2. Replace the shortcode with its expected output, i.e. the full image. Expect neither + * `fetchpriority="high"` nor `loading="lazy"`. + */ + $expected_content = $post_content; + $expected_content = str_replace( + ' false, + 'fetchpriority' => false, + 'loading' => false, + ) + ) + ), + $expected_content + ); + + // Create post with the content. + $post_id = self::factory()->post->create( + array( + 'post_content' => $post_content, + 'post_excerpt' => '', + ) + ); + + // We have to run a main query loop so that the first 'the_content' context images are not lazy-loaded. + $wp_query = new WP_Query( array( 'post__in' => array( $post_id ) ) ); + $wp_the_query = $wp_query; + + $content = ''; + while ( have_posts() ) { + the_post(); + $content = get_echo( 'the_content' ); + } + + // Cleanup. + remove_shortcode( 'full_image' ); + + $this->assertSame( $expected_content, $content ); + } + + /** + * Tests that wp_filter_content_tags() and more specifically wp_get_loading_optimization_attributes() correctly + * handle shortcodes images within the content, including within a block. + * + * Images within shortcodes as part of the content should be ignored by wp_get_loading_optimization_attributes() to + * avoid double processing. They should instead only be processed together with any other images as part of the + * content, to correctly count the original sequencing of those images. + * + * @ticket 58853 + * + * @covers ::wp_filter_content_tags + * @covers ::wp_get_loading_optimization_attributes + */ + public function test_wp_filter_content_tags_handles_shortcode_images_also_in_blocks_within_the_content() { + global $wp_query, $wp_the_query; + + // Disable addition of `decoding="async"` as it is irrelevant for this test. + add_filter( + 'wp_get_loading_optimization_attributes', + static function ( $loading_attrs ) { + if ( isset( $loading_attrs['decoding'] ) ) { + unset( $loading_attrs['decoding'] ); + } + return $loading_attrs; + } + ); + + // Add shortcode that prints a large image, and a block type that wraps it. + add_shortcode( + 'full_image', + static function ( $atts ) { + $atts = shortcode_atts( + array( + 'id' => 0, + ), + $atts, + 'full_image' + ); + return wp_get_attachment_image( (int) $atts['id'], 'full' ); + } + ); + register_block_type( + 'core/full-image-shortcode', + array( + 'render_callback' => static function ( $atts ) { + if ( empty( $atts['id'] ) ) { + return ''; + } + return do_shortcode( '[full_image id="' . $atts['id'] . '"]' ); + }, + ) + ); + + /* + * Include the following images: + * 1. Using gallery shortcode. Expected `fetchpriority="high"`. + * 2. Regular hard-coded image. + * 3. Using custom shortcode within block. + * 4. Regular hard-coded image. Expected `loading="lazy"`. + * + * The first image is expected to be prioritized because it is the first (large enough) content image. + * The first three images are expected to not have lazy-loading because that is the default threshold for + * omitting the attribute. + * The fourth image is expected to be lazy-loaded as it is past the default threshold. + * + * The results will only be correct if all images are considered together. For example: + * * If the image within the shortcode would only be parsed after the rest of the content, it would miss the + * `fetchpriority="high"` attribute and instead incorrectly receive `loading="lazy"`. The second image would as + * a result incorrectly receive `fetchpriority="high"`. + * * If the image within the block would be parsed before the rest of the content, it would incorrectly receive + * the `fetchpriority="high"` attribute. Then the first image would no longer receive the attribute. + * + * To ensure that this works: + * * `wp_filter_content_tags()` must run after `do_blocks()` and `do_shortcode()`. + * * `wp_get_loading_optimization_attributes()` must bail early if any images from the content blob are being + * considered under a different context name than 'the_content'. + */ + $post_content = '[gallery ids="' . self::$large_id . '" size="large"]' . "\n"; + $post_content .= '' . "\n"; + $post_content .= '

Some text.

' . "\n"; + $post_content .= '' . "\n"; + $post_content .= ''; + + $post_id = self::factory()->post->create( + array( + 'post_content' => $post_content, + 'post_excerpt' => '', + ) + ); + + /* + * Prepare the expected output: + * 1. Replace the shortcode with its expected output (ID increased by 1 because of static variable within + * the gallery_shortcode() function). Expect `fetchpriority="high"`, but not `loading="lazy"`. + * 2. Do not modify the second image as it is hard-coded in the content and expected to be unchanged. + * 3. Replace the block with its expected output, i.e. the full image from the shortcode within. Expect neither + * `fetchpriority="high"` nor `loading="lazy"`. + * 4. On the fourth image (hard-coded in the content), expect `loading="lazy"`. + */ + $expected_content = $post_content; + $expected_content = str_replace( + '[gallery ids="' . self::$large_id . '" size="large"]', + str_replace( + array( ' loading="lazy"', '', + wp_get_attachment_image( + self::$large_id, + 'full', + false, + array( + 'fetchpriority' => false, + 'loading' => false, + ) + ), + $expected_content + ); + $expected_content = str_replace( + ' array( $post_id ) ) ); + $wp_the_query = $wp_query; + + $content = ''; + while ( have_posts() ) { + the_post(); + $content = get_echo( 'the_content' ); + } + + // Cleanup. + remove_shortcode( 'full_image' ); + unregister_block_type( 'core/full-image-shortcode' ); + + $this->assertSame( $expected_content, $content ); + } + private function reset_content_media_count() { // Get current value without increasing. $content_media_count = wp_increase_content_media_count( 0 ); @@ -5318,67 +5566,73 @@ EOF; } /** - * @ticket 58681 + * Tests that the `do_shortcode` context results in a lazy-loaded image by default. * - * @dataProvider data_wp_get_loading_optimization_attributes_in_shortcodes + * @ticket 58681 + * @ticket 58853 + * + * @covers ::wp_get_loading_optimization_attributes */ - public function test_wp_get_loading_optimization_attributes_in_shortcodes( $setup, $expected, $message ) { + public function test_wp_get_loading_optimization_attributes_in_shortcodes() { $attr = $this->get_width_height_for_high_priority(); - $setup(); - // The first image processed in a shortcode should have fetchpriority set to high. + // Shortcodes processed outside of content blobs like 'the_content' always get `loading="lazy"`. $this->assertSameSetsWithIndex( - $expected, + array( + 'decoding' => 'async', + 'loading' => 'lazy', + ), wp_get_loading_optimization_attributes( 'img', $attr, 'do_shortcode' ), - $message + 'Lazy-loading not applied to shortcodes outside the loop.' ); } - public function data_wp_get_loading_optimization_attributes_in_shortcodes() { - return array( - 'main_shortcode_image_should_have_fetchpriority_high' => array( - 'setup' => function () { - global $wp_query; + /** + * Tests that the `do_shortcode` context does not result in loading optimization changes when used within a content + * blob. + * + * @ticket 58853 + * + * @covers ::wp_get_loading_optimization_attributes + * + * @dataProvider data_get_filters_with_do_shortcode_callback + * + * @param string $filter_name The name of the filter to hook. + */ + public function test_wp_get_loading_optimization_attributes_in_shortcodes_within_content_blob( $filter_name ) { + $result = null; - // Set WP_Query to be in the loop and the main query. - $wp_query->in_the_loop = true; - $this->set_main_query( $wp_query ); - }, - 'expected' => array( - 'decoding' => 'async', - 'fetchpriority' => 'high', - ), - 'message' => 'Fetch priority not applied to during shortcode rendering.', - ), - 'main_shortcode_image_after_threshold_is_loading_lazy' => array( - 'setup' => function () { - global $wp_query; + remove_all_filters( $filter_name ); + add_filter( + $filter_name, + function ( $content ) use ( &$result ) { + $attr = $this->get_width_height_for_high_priority(); + $result = wp_get_loading_optimization_attributes( 'img', $attr, 'do_shortcode' ); + return $content; + } + ); + apply_filters( $filter_name, '' ); - // Set WP_Query to be in the loop and the main query. - $wp_query->in_the_loop = true; - $this->set_main_query( $wp_query ); + // Shortcodes processed within content blobs like 'the_content' should never get any loading optimization attributes. + $this->assertSame( + array(), + $result, + 'Loading optimization unexpectedly applied to shortcodes within content blob.' + ); + } - // Set internal flags so lazy should be applied. - wp_high_priority_element_flag( false ); - wp_increase_content_media_count( 3 ); - }, - 'expected' => array( - 'decoding' => 'async', - 'loading' => 'lazy', - ), - 'message' => 'Lazy-loading or decoding not applied to during shortcode rendering.', - ), - 'shortcode_image_outside_of_the_loop_are_loaded_lazy' => array( - 'setup' => function () { - // Avoid setting up the WP_Query object for the loop. - return; - }, - 'expected' => array( - 'decoding' => 'async', - 'loading' => 'lazy', - ), - 'message' => 'Lazy-loading or decoding not applied to shortcodes outside the loop.', - ), + /** + * Gets filters for content blobs that by default have a `do_shortcode()` callback. + * + * @return array[] + */ + public function data_get_filters_with_do_shortcode_callback() { + return self::text_array_to_dataprovider( + array( + 'the_content', + 'widget_text_content', + 'widget_block_content', + ) ); }