diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index abbf18c3b74d2..1c2b460d1cbfb 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -4050,6 +4050,19 @@ function wp_trim_excerpt( $text = '', $post = null ) { */ $filter_block_removed = remove_filter( 'the_content', 'do_blocks', 9 ); + /* + * Temporarily unhook apply_block_hooks_to_content_from_post_object(). + * The excerpt is generated from already-rendered content and + * do_blocks() is unhooked above, so any hooked blocks inserted here + * could never be rendered. The callback wraps delimiter-free content + * in a temporary core/post-content wrapper block to anchor hooked + * blocks, so the algorithm would still run and any markup added via + * the 'hooked_block' filter would leak into the excerpt as text. It + * would also resolve its context from the current loop post via + * get_post() rather than the post being excerpted. + */ + $filter_block_hooks_removed = remove_filter( 'the_content', 'apply_block_hooks_to_content_from_post_object', 8 ); + /** This filter is documented in wp-includes/post-template.php */ $text = apply_filters( 'the_content', $text ); $text = str_replace( ']]>', ']]>', $text ); @@ -4059,6 +4072,10 @@ function wp_trim_excerpt( $text = '', $post = null ) { add_filter( 'the_content', 'do_blocks', 9 ); } + if ( $filter_block_hooks_removed ) { + add_filter( 'the_content', 'apply_block_hooks_to_content_from_post_object', 8 ); + } + /* * Only restore the filter callback if it was removed above. The logic * to unhook and restore only applies on the default priority of 10, diff --git a/tests/phpunit/tests/formatting/wpTrimExcerpt.php b/tests/phpunit/tests/formatting/wpTrimExcerpt.php index 688ba7731f1ce..c2f195b50b580 100644 --- a/tests/phpunit/tests/formatting/wpTrimExcerpt.php +++ b/tests/phpunit/tests/formatting/wpTrimExcerpt.php @@ -223,4 +223,194 @@ public function test_wp_trim_excerpt_does_not_restore_do_blocks_if_previously_un // Assert that the filter callback was not restored after running 'the_content'. $this->assertFalse( has_filter( 'the_content', 'do_blocks' ) ); } + + /** + * Tests that `wp_trim_excerpt()` unhooks `apply_block_hooks_to_content_from_post_object()` from 'the_content' filter. + * + * @ticket 66110 + */ + public function test_wp_trim_excerpt_unhooks_apply_block_hooks_to_content_from_post_object() { + $post = self::factory()->post->create(); + + /* + * Record that during 'the_content' filter run by wp_trim_excerpt() the + * apply_block_hooks_to_content_from_post_object() callback is not used. + */ + $has_filter = true; + add_filter( + 'the_content', + static function ( $content ) use ( &$has_filter ) { + $has_filter = has_filter( 'the_content', 'apply_block_hooks_to_content_from_post_object' ); + return $content; + } + ); + + wp_trim_excerpt( '', $post ); + + $this->assertFalse( $has_filter, 'apply_block_hooks_to_content_from_post_object() was not unhooked in wp_trim_excerpt()' ); + } + + /** + * Tests that `wp_trim_excerpt()` doesn't permanently unhook `apply_block_hooks_to_content_from_post_object()` from 'the_content' filter. + * + * @ticket 66110 + */ + public function test_wp_trim_excerpt_should_not_permanently_unhook_apply_block_hooks_to_content_from_post_object() { + $post = self::factory()->post->create(); + + wp_trim_excerpt( '', $post ); + + $this->assertSame( 8, has_filter( 'the_content', 'apply_block_hooks_to_content_from_post_object' ), 'apply_block_hooks_to_content_from_post_object() was not restored in wp_trim_excerpt()' ); + } + + /** + * Tests that `wp_trim_excerpt()` doesn't restore `apply_block_hooks_to_content_from_post_object()` if it was previously unhooked. + * + * @ticket 66110 + */ + public function test_wp_trim_excerpt_does_not_restore_apply_block_hooks_to_content_from_post_object_if_previously_unhooked() { + $post = self::factory()->post->create(); + + // Remove apply_block_hooks_to_content_from_post_object() from 'the_content' filter generally. + remove_filter( 'the_content', 'apply_block_hooks_to_content_from_post_object', 8 ); + + wp_trim_excerpt( '', $post ); + + // Assert that the filter callback was not restored after running 'the_content'. + $this->assertFalse( has_filter( 'the_content', 'apply_block_hooks_to_content_from_post_object' ) ); + } + + /** + * Tests that the Block Hooks algorithm is not run while `wp_trim_excerpt()` + * generates an excerpt. + * + * The 'hooked_block_types' filter only fires inside the Block Hooks + * algorithm, so counting its invocations detects whether the algorithm ran. + * + * @ticket 66110 + */ + public function test_wp_trim_excerpt_does_not_run_block_hooks_algorithm() { + $post = self::factory()->post->create( + array( + 'post_content' => '
A test paragraph.
', + ) + ); + + register_block_type( + 'tests/hooked-block', + array( + 'block_hooks' => array( + 'core/post-content' => 'last_child', + ), + ) + ); + + $hooked_block_types_count = 0; + $spy = static function ( $hooked_block_types ) use ( &$hooked_block_types_count ) { + ++$hooked_block_types_count; + return $hooked_block_types; + }; + add_filter( 'hooked_block_types', $spy ); + + wp_trim_excerpt( '', $post ); + + remove_filter( 'hooked_block_types', $spy ); + unregister_block_type( 'tests/hooked-block' ); + + $this->assertSame( 0, $hooked_block_types_count, 'Block Hooks algorithm ran during wp_trim_excerpt()' ); + } + + /** + * Tests that markup of a hooked block does not leak into the generated excerpt. + * + * apply_block_hooks_to_content_from_post_object() wraps delimiter-free + * content in a temporary core/post-content wrapper block, which acts as an + * anchor for hooked blocks. Since do_blocks() is unhooked while the excerpt + * is generated, an inserted hooked block is never rendered, and any markup + * provided via the 'hooked_block' filter would leak into the excerpt as + * plain text once the block delimiters are stripped. + * + * @ticket 66110 + */ + public function test_wp_trim_excerpt_does_not_leak_hooked_block_markup() { + $post = self::factory()->post->create( + array( + 'post_content' => 'Visible text.
', + ) + ); + + // Emulate a front-end loop, as the callback resolves context via get_post(). + $GLOBALS['post'] = get_post( $post ); + setup_postdata( $GLOBALS['post'] ); + + register_block_type( + 'tests/hooked-block', + array( + 'block_hooks' => array( + 'core/post-content' => 'last_child', + ), + ) + ); + + // Give the hooked block actual markup, as the 'hooked_block' filter allows. + $filter = static function ( array $parsed_hooked_block ): array { + $parsed_hooked_block['innerHTML'] = 'LEAKED HOOKED TEXT
'; + $parsed_hooked_block['innerContent'] = array( 'LEAKED HOOKED TEXT
' ); + return $parsed_hooked_block; + }; + add_filter( 'hooked_block', $filter ); + + $excerpt = wp_trim_excerpt( '', $post ); + + remove_filter( 'hooked_block', $filter ); + unregister_block_type( 'tests/hooked-block' ); + + $this->assertSame( 'Visible text.', $excerpt, 'Hooked block markup leaked into the excerpt generated by wp_trim_excerpt()' ); + } + + /** + * Tests that Block Hooks still run on 'the_content' after `wp_trim_excerpt()` + * has generated an excerpt. + * + * @ticket 66110 + */ + public function test_wp_trim_excerpt_restores_block_hooks_for_subsequent_the_content() { + $post = self::factory()->post->create( + array( + 'post_content' => 'Visible text.
', + ) + ); + + // Emulate a front-end loop, as the callback resolves context via get_post(). + $GLOBALS['post'] = get_post( $post ); + setup_postdata( $GLOBALS['post'] ); + + register_block_type( + 'tests/hooked-block', + array( + 'block_hooks' => array( + 'core/post-content' => 'last_child', + ), + ) + ); + + $filter = static function ( array $parsed_hooked_block ): array { + $parsed_hooked_block['innerHTML'] = 'HOOKED TEXT
'; + $parsed_hooked_block['innerContent'] = array( 'HOOKED TEXT
' ); + return $parsed_hooked_block; + }; + add_filter( 'hooked_block', $filter ); + + // Generate an excerpt, which unhooks and restores the callback. + wp_trim_excerpt( '', $post ); + + // A subsequent front-end run of 'the_content' must still apply Block Hooks. + /** This filter is documented in wp-includes/post-template.php */ + $content = apply_filters( 'the_content', get_the_content( '', false, $post ) ); + + remove_filter( 'hooked_block', $filter ); + unregister_block_type( 'tests/hooked-block' ); + + $this->assertStringContainsString( 'HOOKED TEXT', $content, 'Block Hooks did not run on the_content after wp_trim_excerpt()' ); + } }