From 424ca0c6c93e99d16416f8f226fa2d07554a29a3 Mon Sep 17 00:00:00 2001 From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:30:37 +0200 Subject: [PATCH] Block Bindings: Preserve nested inner blocks when binding rich text. `WP_Block::replace_html()` replaced the entire element matched by a rich-text attribute's selector, dropping any markup produced by inner blocks rendered inside that same element (e.g. a List nested inside a List Item). Record, during render, the byte offset where each inner block's rendered output begins, and pass them to `replace_rich_text()` so the replacement stops at the first inner block inside the selector. A block's own rich text always precedes its inner blocks, so only that rich text is replaced and inner block output is preserved verbatim. Offsets are collected only when the block has bound attributes, and are dropped once a replacement shifts byte positions. The change is block-agnostic; tests cover it with an arbitrary block type and the rich-text processor, plus List Item integration cases (nested lists, multibyte fallbacks, ordered lists, pattern overrides and inner-block-before- text) that build on the List Item content binding. Props sauliusv, ockham, cbravobernal. See https://core.trac.wordpress.org/ticket/65406 --- src/wp-includes/class-wp-block.php | 87 +++- tests/phpunit/tests/block-bindings/render.php | 484 +++++++++++++++++- .../wp-block-get-block-bindings-processor.php | 27 + 3 files changed, 586 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/class-wp-block.php b/src/wp-includes/class-wp-block.php index f59b770d93b5b..8baf0f99bacde 100644 --- a/src/wp-includes/class-wp-block.php +++ b/src/wp-includes/class-wp-block.php @@ -364,13 +364,16 @@ private function process_block_bindings() { * Depending on the block attribute name, replace its value in the HTML based on the value provided. * * @since 6.5.0 + * @since 7.1.0 Added the optional `$inner_block_offsets` parameter. * - * @param string $block_content Block content. - * @param string $attribute_name The attribute name to replace. - * @param mixed $source_value The value used to replace in the HTML. + * @param string $block_content Block content. + * @param string $attribute_name The attribute name to replace. + * @param mixed $source_value The value used to replace in the HTML. + * @param int[] $inner_block_offsets Optional. Byte offsets where each inner block's + * rendered output begins. Default empty array. * @return string The modified block content. */ - private function replace_html( string $block_content, string $attribute_name, $source_value ) { + private function replace_html( string $block_content, string $attribute_name, $source_value, array $inner_block_offsets = array() ) { $block_type = $this->block_type; if ( ! isset( $block_type->attributes[ $attribute_name ]['source'] ) ) { return $block_content; @@ -396,9 +399,16 @@ private function replace_html( string $block_content, string $attribute_name, $s 'tag_name' => $selector, ) ) ) { - // TODO: Use `WP_HTML_Processor::set_inner_html` method once it's available. + /* + * TODO: Use `WP_HTML_Processor::set_inner_html()` once it's available. + * Any replacement must preserve already-rendered inner block + * markup verbatim (it may come from dynamic blocks), so it + * cannot re-serialize the element's contents. Until an API with + * that guarantee exists, the replacement is spliced by byte + * offset, leaving inner block output untouched. + */ $block_reader->release_bookmark( 'iterate-selectors' ); - $block_reader->replace_rich_text( wp_kses_post( $source_value ) ); + $block_reader->replace_rich_text( wp_kses_post( $source_value ), $inner_block_offsets ); return $block_reader->get_updated_html(); } else { $block_reader->seek( 'iterate-selectors' ); @@ -433,10 +443,17 @@ private static function get_block_bindings_processor( string $block_content ) { * When stopped on a tag opener, replace the content enclosed by it and its * matching closer with the provided rich text. * - * @param string $rich_text The rich text to replace the original content with. + * If byte offsets of inner blocks' rendered output are provided, the + * replacement stops at the first inner block found inside the element, + * preserving any markup produced by nested inner blocks (e.g. a List + * block nested inside a List Item). + * + * @param string $rich_text The rich text to replace the original content with. + * @param int[] $inner_block_offsets Optional. Byte offsets in the source HTML where + * inner blocks' rendered output begins. Default empty array. * @return bool True on success. */ - public function replace_rich_text( $rich_text ) { + public function replace_rich_text( $rich_text, $inner_block_offsets = array() ) { if ( $this->is_tag_closer() || ! $this->expects_closer() ) { return false; } @@ -461,6 +478,25 @@ public function replace_rich_text( $rich_text ) { $tag_closer = $this->bookmarks['__wp_block_bindings']; $end = $tag_closer->start; + /* + * Stop at the first inner block that renders inside this element so + * its markup is preserved. The block's own rich text always precedes + * its inner blocks, so replacing up to the first inner block offset + * replaces only that rich text. Offsets are recorded during render in + * the same byte coordinates as this fragment, and are in ascending + * order, so the first match is the earliest inner block. + * + * The lower bound is inclusive of `$start`: when an inner block + * begins immediately, with no leading rich text, the (empty) rich + * text is still replaced instead of the inner block markup. + */ + foreach ( $inner_block_offsets as $inner_block_offset ) { + if ( $inner_block_offset >= $start && $inner_block_offset < $end ) { + $end = $inner_block_offset; + break; + } + } + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end - $start, @@ -479,6 +515,7 @@ public function replace_rich_text( $rich_text ) { * * @since 5.5.0 * @since 6.5.0 Added block bindings processing. + * @since 7.1.0 Preserve inner blocks when binding a rich text attribute. * * @global WP_Post $post Global post object. * @@ -538,6 +575,19 @@ public function render( $options = array() ) { $is_dynamic = $options['dynamic'] && $this->name && null !== $this->block_type && $this->block_type->is_dynamic(); $block_content = ''; + /* + * Byte offsets in $block_content where each inner block's rendered output + * begins. Block bindings rich-text replacement uses these to stop at the + * first inner block inside a selector, so it replaces only the block's own + * rich text and never the markup produced by nested inner blocks. + * + * They are only collected when the block has bound attributes to resolve; + * otherwise they are never read, so recording them would add work to every + * block render for no benefit. + */ + $inner_block_offsets = array(); + $collect_offsets = ! empty( $computed_attributes ); + if ( ! $options['dynamic'] || empty( $this->block_type->skip_inner_blocks ) ) { $index = 0; @@ -545,6 +595,9 @@ public function render( $options = array() ) { if ( is_string( $chunk ) ) { $block_content .= $chunk; } else { + if ( $collect_offsets ) { + $inner_block_offsets[] = strlen( $block_content ); + } $inner_block = $this->inner_blocks[ $index ]; $parent_block = $this; @@ -583,7 +636,23 @@ public function render( $options = array() ) { if ( ! empty( $computed_attributes ) && ! empty( $block_content ) ) { foreach ( $computed_attributes as $attribute_name => $source_value ) { - $block_content = $this->replace_html( $block_content, $attribute_name, $source_value ); + $updated_block_content = $this->replace_html( $block_content, $attribute_name, $source_value, $inner_block_offsets ); + + /* + * The offsets describe $block_content as it was assembled. A + * replacement that modifies the markup shifts byte positions, so + * once the content changes the remaining attributes fall back to + * offset-free replacement rather than clamp at a stale position. + * Attributes that leave the markup untouched keep the offsets + * valid: the computed `metadata` attribute produced by a pattern + * overrides `__default` binding has no HTML source, so it must + * not invalidate the offsets for the rich text that follows it. + */ + if ( $updated_block_content !== $block_content ) { + $inner_block_offsets = array(); + } + + $block_content = $updated_block_content; } } diff --git a/tests/phpunit/tests/block-bindings/render.php b/tests/phpunit/tests/block-bindings/render.php index 172bdff71315d..3ce1993e4c351 100644 --- a/tests/phpunit/tests/block-bindings/render.php +++ b/tests/phpunit/tests/block-bindings/render.php @@ -12,9 +12,7 @@ class WP_Block_Bindings_Render extends WP_UnitTestCase { const SOURCE_NAME = 'test/source'; - const SOURCE_LABEL = array( - 'label' => 'Test source', - ); + const SOURCE_LABEL = 'Test source'; /** * Sets up shared fixtures. @@ -433,4 +431,484 @@ public function test_filter_block_bindings_source_value() { 'The block content should show the filtered value.' ); } + + /** + * Provides fuzz-style nested list fixtures for rich text binding tests. + * + * The fixtures vary whether fallback rich text exists before the first inner + * block, whether that fallback contains raw markup or multibyte text, whether + * nested lists are ordered, and whether siblings surround the bound item. + * + * @return array[] + */ + public function data_rich_text_binding_preserves_nested_inner_blocks() { + $child_list = self::build_list_block( + array( + self::build_list_item_block( 'Nested child' ), + ) + ); + + $deep_child_list = self::build_list_block( + array( + self::build_list_item_block( + 'Nested parent' . self::build_list_block( + array( + self::build_list_item_block( 'Nested grandchild' ), + ) + ) + ), + ) + ); + + $ordered_child_list = self::build_list_block( + array( + self::build_list_item_block( 'Ordered child' ), + self::build_list_item_block( 'Second ordered child' ), + ), + array( + 'ordered' => true, + 'start' => 3, + ) + ); + + return array( + 'nested list after fallback text' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( 'Default content' . $child_list, true ), + ) + ), + 'bound_value' => 'Bound list item', + 'expected_rendered_block' => << +
  • Bound list item + +
  • + +HTML + , + 'removed_strings' => array( 'Default content' ), + 'preserved_strings' => array( 'Nested child' ), + ), + 'raw markup before nested list' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( 'Default content' . $child_list, true ), + ) + ), + 'bound_value' => 'Bound list item', + 'expected_rendered_block' => << +
  • Bound list item + +
  • + +HTML + , + 'removed_strings' => array( 'Default content', 'Raw markup to replace' ), + 'preserved_strings' => array( 'Nested child' ), + ), + 'inner block starts at rich text boundary' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( $child_list, true ), + ) + ), + 'bound_value' => 'Bound list item', + 'expected_rendered_block' => << +
  • Bound list item + +
  • + +HTML + , + 'removed_strings' => array(), + 'preserved_strings' => array( 'Nested child' ), + ), + 'multibyte fallback before nested list' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( 'Café fallback before nested list' . $child_list, true ), + ) + ), + 'bound_value' => 'Bound línea', + 'expected_rendered_block' => << +
  • Bound línea + +
  • + +HTML + , + 'removed_strings' => array( 'Café fallback', 'nested' ), + 'preserved_strings' => array( 'Nested child', 'Bound línea' ), + ), + 'deep nested list with sibling item' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( 'Default parent' . $deep_child_list, true ), + self::build_list_item_block( 'Sibling stays' ), + ) + ), + 'bound_value' => 'Bound parent', + 'expected_rendered_block' => << +
  • Bound parent + +
  • + +
  • Sibling stays
  • + +HTML + , + 'removed_strings' => array( 'Default parent' ), + 'preserved_strings' => array( 'Nested parent', 'Nested grandchild', 'Sibling stays' ), + ), + 'ordered nested list with attributes' => array( + 'block_content' => self::build_list_block( + array( + self::build_list_item_block( 'Default ordered parent' . $ordered_child_list, true ), + ) + ), + 'bound_value' => 'Bound ordered parent', + 'expected_rendered_block' => << +
  • Bound ordered parent +
      +
    1. Ordered child
    2. + +
    3. Second ordered child
    4. +
    +
  • + +HTML + , + 'removed_strings' => array( 'Default ordered parent' ), + 'preserved_strings' => array( 'Ordered child', 'Second ordered child', 'start="3"' ), + ), + ); + } + + /** + * Tests that binding a List Item block's rich text preserves nested List + * inner blocks rendered inside the same `
  • ` element. + * + * @ticket 65406 + * + * @covers WP_Block::render + * + * @dataProvider data_rich_text_binding_preserves_nested_inner_blocks + */ + public function test_rich_text_binding_preserves_nested_inner_blocks( $block_content, $bound_value, $expected_rendered_block, $removed_strings, $preserved_strings ) { + register_block_bindings_source( + self::SOURCE_NAME, + array( + 'label' => self::SOURCE_LABEL, + 'get_value_callback' => static function () use ( $bound_value ) { + return $bound_value; + }, + ) + ); + + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( $parsed_blocks[0] ); + $result = $block->render(); + + foreach ( $removed_strings as $removed_string ) { + $this->assertStringNotContainsString( + $removed_string, + $result, + "Fallback content '{$removed_string}' should be replaced by the source value." + ); + } + + foreach ( $preserved_strings as $preserved_string ) { + $this->assertStringContainsString( + $preserved_string, + $result, + "Nested inner block content '{$preserved_string}' should be preserved." + ); + } + + $this->assertEqualHTML( + $expected_rendered_block, + trim( $result ), + '', + 'The bound list item rich text should be replaced without dropping nested inner blocks.' + ); + $this->assertSame( + $bound_value, + $block->inner_blocks[0]->attributes['content'], + 'The bound list item content attribute should be updated with the source value.' + ); + } + + /** + * Tests that inner-block preservation is block-agnostic. + * + * The replacement logic has no block-specific handling: it relies only on + * where inner blocks render. This registers an arbitrary block whose bound + * rich text and an inner block share the same element, and confirms the inner + * block is preserved exactly as it is for `core/list-item`. + * + * @ticket 65406 + * + * @covers WP_Block::render + */ + public function test_rich_text_binding_preserves_inner_blocks_for_any_block() { + register_block_type( + 'test/rich-text-with-inner-blocks', + array( + 'attributes' => array( + 'content' => array( + 'type' => 'rich-text', + 'source' => 'rich-text', + 'selector' => 'div', + ), + ), + ) + ); + + $supported_attributes_filter = static function ( $supported_attributes, $block_type ) { + if ( 'test/rich-text-with-inner-blocks' === $block_type ) { + $supported_attributes[] = 'content'; + } + return $supported_attributes; + }; + + add_filter( + 'block_bindings_supported_attributes', + $supported_attributes_filter, + 10, + 2 + ); + + register_block_bindings_source( + self::SOURCE_NAME, + array( + 'label' => self::SOURCE_LABEL, + 'get_value_callback' => static function () { + return 'Bound value'; + }, + ) + ); + + $block_content = << +
    +

    Inner paragraph stays

    +
    + +HTML; + + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( $parsed_blocks[0] ); + $result = $block->render(); + + remove_filter( 'block_bindings_supported_attributes', $supported_attributes_filter, 10 ); + unregister_block_type( 'test/rich-text-with-inner-blocks' ); + + $expected_rendered_block = <<Bound value +

    Inner paragraph stays

    + +HTML; + + $this->assertEqualHTML( + $expected_rendered_block, + trim( $result ), + '', + 'The inner block should be preserved for any block, not just core/list-item.' + ); + } + + /** + * Tests that a pattern overrides `__default` binding preserves nested List + * inner blocks. + * + * Pattern overrides expand the `__default` binding into computed attributes + * that include the rewritten `metadata` attribute alongside `content`. The + * `metadata` attribute has no HTML source, so its no-op replacement must not + * invalidate the inner-block offsets used to preserve the nested list when + * `content` is replaced afterwards. + * + * @ticket 65406 + * + * @covers WP_Block::render + */ + public function test_pattern_overrides_binding_preserves_nested_inner_blocks() { + $block_content = << + + +HTML; + + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( + $parsed_blocks[0], + array( + 'pattern/overrides' => array( + 'Editable List Item' => array( 'content' => 'Pattern override' ), + ), + ) + ); + $result = $block->render(); + + $expected_rendered_block = << +
  • Pattern override + +
  • + +HTML; + + $this->assertEqualHTML( + $expected_rendered_block, + trim( $result ), + '', + 'The pattern override should replace the list item rich text without dropping the nested list.' + ); + $this->assertSame( + 'Pattern override', + $block->inner_blocks[0]->attributes['content'], + 'The list item content attribute should be updated with the pattern override value.' + ); + } + + /** + * Tests that binding degrades safely when rich text does not precede the + * inner block. + * + * The replacement assumes a block's own rich text comes before its inner + * blocks, which holds for a normally authored List Item. When markup is + * authored with the nested list first, the replacement stops at that inner + * block: the bound value is written ahead of it and the trailing rich text + * is left in place. The result is an incomplete replacement, never broken + * structure, and the nested inner block is still preserved. + * + * @ticket 65406 + * + * @covers WP_Block::render + */ + public function test_rich_text_binding_with_inner_block_before_text() { + $block_content = << + + +HTML; + + register_block_bindings_source( + self::SOURCE_NAME, + array( + 'label' => self::SOURCE_LABEL, + 'get_value_callback' => static function () { + return 'Bound value'; + }, + ) + ); + + $parsed_blocks = parse_blocks( $block_content ); + $block = new WP_Block( $parsed_blocks[0] ); + $result = $block->render(); + + $expected_rendered_block = << +
  • Bound value + +trailing text
  • + +HTML; + + $this->assertEqualHTML( + $expected_rendered_block, + trim( $result ), + '', + 'The bound value should be written before the inner block while preserving the nested list and the trailing rich text.' + ); + $this->assertStringContainsString( + 'Nested child', + $result, + 'The nested list inner block should be preserved.' + ); + $this->assertStringContainsString( + 'trailing text', + $result, + 'Rich text after the inner block should be left untouched.' + ); + } + + /** + * Builds List block markup. + * + * @param string[] $items Serialized List Item blocks. + * @param array $attributes Optional List block attributes. + * @return string Serialized List block markup. + */ + private static function build_list_block( $items, $attributes = array() ) { + $is_ordered = ! empty( $attributes['ordered'] ); + $tag_name = $is_ordered ? 'ol' : 'ul'; + $block_attributes = $attributes ? ' ' . wp_json_encode( $attributes ) : ''; + $html_attributes = ' class="wp-block-list"'; + + if ( isset( $attributes['start'] ) ) { + $html_attributes .= ' start="' . (int) $attributes['start'] . '"'; + } + + return sprintf( + "\n<%s%s>%s\n", + $block_attributes, + $tag_name, + $html_attributes, + implode( '', $items ), + $tag_name + ); + } + + /** + * Builds List Item block markup. + * + * @param string $content List item inner HTML. + * @param bool $is_bound Optional. Whether to bind the content attribute. + * @return string Serialized List Item block markup. + */ + private static function build_list_item_block( $content, $is_bound = false ) { + $block_attributes = $is_bound ? ' {"metadata":{"bindings":{"content":{"source":"test/source"}}}}' : ''; + + return sprintf( + "\n
  • %s
  • \n", + $block_attributes, + $content + ); + } } diff --git a/tests/phpunit/tests/block-bindings/wp-block-get-block-bindings-processor.php b/tests/phpunit/tests/block-bindings/wp-block-get-block-bindings-processor.php index afdad9bd28512..f7f65b56738a9 100644 --- a/tests/phpunit/tests/block-bindings/wp-block-get-block-bindings-processor.php +++ b/tests/phpunit/tests/block-bindings/wp-block-get-block-bindings-processor.php @@ -40,6 +40,33 @@ public function test_replace_rich_text() { ); } + /** + * @ticket 65406 + */ + public function test_replace_rich_text_stops_at_inner_block_offset() { + $item_opener = '
  • '; + $rich_text = 'This should not appear'; + $nested_list = ''; + $item_closer = '
  • '; + + $processor = self::$get_block_bindings_processor_method->invoke( + null, + $item_opener . $rich_text . $nested_list . $item_closer + ); + $processor->next_tag( array( 'tag_name' => 'li' ) ); + + $this->assertTrue( + $processor->replace_rich_text( + 'New list item content', + array( strlen( $item_opener . $rich_text ) ) + ) + ); + $this->assertEquals( + $item_opener . 'New list item content' . $nested_list . $item_closer, + $processor->get_updated_html() + ); + } + /** * @ticket 63840 */