From b151a33cdac50f2353e64a6d452999cd505494df Mon Sep 17 00:00:00 2001 From: cbravobernal Date: Thu, 18 Jun 2026 10:37:43 +0000 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 element (e.g. a List nested inside a List Item). Props cbravobernal, jonsurrell. Fixes #65406. Built from https://develop.svn.wordpress.org/trunk@62522 git-svn-id: http://core.svn.wordpress.org/trunk@61803 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- wp-includes/class-wp-block.php | 87 ++++++++++++++++++++++++++++++---- wp-includes/version.php | 2 +- 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/wp-includes/class-wp-block.php b/wp-includes/class-wp-block.php index f59b770d93..8baf0f99ba 100644 --- a/wp-includes/class-wp-block.php +++ b/wp-includes/class-wp-block.php @@ -364,13 +364,16 @@ class WP_Block { * 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 @@ class WP_Block { '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 @@ class WP_Block { * 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 @@ class WP_Block { $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 @@ class WP_Block { * * @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 @@ class WP_Block { $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 @@ class WP_Block { 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 @@ class WP_Block { 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/wp-includes/version.php b/wp-includes/version.php index 61f98488ba..b5b870e5d8 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '7.1-alpha-62521'; +$wp_version = '7.1-alpha-62522'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.