diff --git a/src/wp-includes/block-bindings.php b/src/wp-includes/block-bindings.php
index 268bb6afa66bb..5a20c023149d7 100644
--- a/src/wp-includes/block-bindings.php
+++ b/src/wp-includes/block-bindings.php
@@ -134,6 +134,7 @@ function get_block_bindings_source( string $source_name ) {
* Retrieves the list of block attributes supported by block bindings.
*
* @since 6.9.0
+ * @since 7.1.0 Added support for the List Item block.
*
* @param string $block_type The block type whose supported attributes are being retrieved.
* @return array The list of block attributes that are supported by block bindings.
@@ -142,6 +143,7 @@ function get_block_bindings_supported_attributes( $block_type ) {
$block_bindings_supported_attributes = array(
'core/paragraph' => array( 'content' ),
'core/heading' => array( 'content' ),
+ 'core/list-item' => array( 'content' ),
'core/image' => array( 'id', 'url', 'title', 'alt', 'caption' ),
'core/button' => array( 'url', 'text', 'linkTarget', 'rel' ),
'core/post-date' => array( 'datetime' ),
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 77b0975105dc5..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.
@@ -122,6 +120,16 @@ public function data_update_block_with_value_from_source() {
,
'
test source value
',
),
+ 'list item block' => array(
+ 'content',
+ <<
+This should not appear
+
+HTML
+ ,
+ 'test source value',
+ ),
);
}
@@ -423,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
+
+- Ordered child
+
+- Second ordered child
+
+
+
+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 = <<
+
+
+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%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
*/