From 04e4eac387a3d5120c53b9faef1fe9a1320cb4a8 Mon Sep 17 00:00:00 2001 From: Marcin Dudek Date: Mon, 14 Sep 2026 18:15:05 +0200 Subject: [PATCH 1/2] Editor: Do not apply Block Hooks when generating post excerpts. wp_trim_excerpt() builds the excerpt from already-rendered content: it runs excerpt_remove_blocks() and temporarily unhooks do_blocks() and wp_filter_content_tags() so the excerpt is not processed a second time. The Block Hooks callback apply_block_hooks_to_content_from_post_object(), added to 'the_content' at priority 8 in [59523], was never unhooked there and still runs during excerpt generation. The callback wraps the content in a temporary core/post-content wrapper block, which anchors first/last-child hooked blocks even when no block delimiters remain. Those blocks can never render, because do_blocks() is unhooked, and any markup they carry via the 'hooked_block' filter leaks into the excerpt text as plain text. The callback also resolves its context from the current loop post rather than the post being excerpted. Unhook and restore it alongside the existing do_blocks()/wp_filter_content_tags() dance, matching the same unhook already performed in insert_hooked_blocks_into_rest_response(). Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 --- src/wp-includes/formatting.php | 17 ++ .../tests/formatting/wpTrimExcerpt.php | 189 ++++++++++++++++++ 2 files changed, 206 insertions(+) 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..91660fbc9a47e 100644 --- a/tests/phpunit/tests/formatting/wpTrimExcerpt.php +++ b/tests/phpunit/tests/formatting/wpTrimExcerpt.php @@ -223,4 +223,193 @@ 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 ( $parsed_hooked_block ) { + $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 ( $parsed_hooked_block ) { + $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. + $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()' ); + } } From e4f013bc8d65b7e2032fa4d94feb23be88059fdf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 15 Sep 2026 12:06:17 -0700 Subject: [PATCH 2/2] Fix static analysis issues --- tests/phpunit/tests/formatting/wpTrimExcerpt.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/formatting/wpTrimExcerpt.php b/tests/phpunit/tests/formatting/wpTrimExcerpt.php index 91660fbc9a47e..c2f195b50b580 100644 --- a/tests/phpunit/tests/formatting/wpTrimExcerpt.php +++ b/tests/phpunit/tests/formatting/wpTrimExcerpt.php @@ -353,7 +353,7 @@ public function test_wp_trim_excerpt_does_not_leak_hooked_block_markup() { ); // Give the hooked block actual markup, as the 'hooked_block' filter allows. - $filter = static function ( $parsed_hooked_block ) { + $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; @@ -394,7 +394,7 @@ public function test_wp_trim_excerpt_restores_block_hooks_for_subsequent_the_con ) ); - $filter = static function ( $parsed_hooked_block ) { + $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; @@ -405,6 +405,7 @@ public function test_wp_trim_excerpt_restores_block_hooks_for_subsequent_the_con 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 );