From 417cff4bdc7296047c5c6a377f6988e9f19ab0d3 Mon Sep 17 00:00:00 2001
From: Carlos Bravo
Date: Thu, 29 Oct 2026 13:37:06 +0100
Subject: [PATCH 1/9] Block Bindings: Support `innerBlocks` bindings on the
Group and Column blocks.
A block bindings source can now supply the inner blocks of a Group or Column block through `metadata.bindings.innerBlocks`. The value is serialized block markup, which replaces the block's own inner blocks before they render. An empty string leaves the block without inner blocks, and any other value keeps its own.
The markup is sanitized with `wp_kses_post()`, as bound rich text already is, and its custom CSS is removed. Supplied blocks can't bind their own inner blocks, so that sources can't expand each other forever. The editor isn't offered `innerBlocks` yet, as it doesn't render these bindings.
Backports WordPress/gutenberg#85568.
Fixes #66415.
Co-Authored-By: Claude Opus 5.5
---
src/wp-includes/block-bindings.php | 3 +
src/wp-includes/block-editor.php | 3 +-
src/wp-includes/class-wp-block.php | 153 +++++++++
.../tests/block-bindings/innerBlocks.php | 312 ++++++++++++++++++
4 files changed, 470 insertions(+), 1 deletion(-)
create mode 100644 tests/phpunit/tests/block-bindings/innerBlocks.php
diff --git a/src/wp-includes/block-bindings.php b/src/wp-includes/block-bindings.php
index 1250f92e99848..7809e05cd4185 100644
--- a/src/wp-includes/block-bindings.php
+++ b/src/wp-includes/block-bindings.php
@@ -138,6 +138,7 @@ function get_block_bindings_source( string $source_name ) {
* @since 6.9.0
* @since 7.1.0 Added support for the List Item block.
* @since 7.2.0 Added support for the Icon block.
+ * @since 7.3.0 Added support for the inner blocks of the Group and Column blocks.
*
* @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.
@@ -153,6 +154,8 @@ function get_block_bindings_supported_attributes( $block_type ) {
'core/navigation-link' => array( 'url' ),
'core/navigation-submenu' => array( 'url' ),
'core/icon' => array( 'icon' ),
+ 'core/group' => array( 'innerBlocks' ),
+ 'core/column' => array( 'innerBlocks' ),
);
$supported_block_attributes =
diff --git a/src/wp-includes/block-editor.php b/src/wp-includes/block-editor.php
index 24bdd2f6db5db..a3d62db114e68 100644
--- a/src/wp-includes/block-editor.php
+++ b/src/wp-includes/block-editor.php
@@ -502,7 +502,8 @@ function get_block_editor_settings( array $custom_settings, $block_editor_contex
$editor_settings['__experimentalBlockBindingsSupportedAttributes'] = array();
foreach ( array_keys( WP_Block_Type_Registry::get_instance()->get_all_registered() ) as $block_type ) {
- $supported_block_attributes = get_block_bindings_supported_attributes( $block_type );
+ // The editor doesn't render inner blocks bindings yet.
+ $supported_block_attributes = array_values( array_diff( get_block_bindings_supported_attributes( $block_type ), array( 'innerBlocks' ) ) );
if ( ! empty( $supported_block_attributes ) ) {
$editor_settings['__experimentalBlockBindingsSupportedAttributes'][ $block_type ] = $supported_block_attributes;
}
diff --git a/src/wp-includes/class-wp-block.php b/src/wp-includes/class-wp-block.php
index 8baf0f99bacde..6d82a1c13e947 100644
--- a/src/wp-includes/class-wp-block.php
+++ b/src/wp-includes/class-wp-block.php
@@ -273,6 +273,7 @@ public function __get( $name ) {
* @since 6.5.0
* @since 6.6.0 Handle the `__default` attribute for pattern overrides.
* @since 6.7.0 Return any updated bindings metadata in the computed attributes.
+ * @since 7.3.0 Replace the inner blocks of the block for an `innerBlocks` binding.
*
* @return array The computed block attributes for the provided block bindings.
*/
@@ -310,6 +311,10 @@ private function process_block_bindings() {
* resulting array.
*/
foreach ( $supported_block_attributes as $attribute_name ) {
+ // Inner blocks are only bound explicitly.
+ if ( 'innerBlocks' === $attribute_name && ! isset( $bindings['innerBlocks'] ) ) {
+ continue;
+ }
// Retain any non-pattern override bindings that might be present.
$updated_bindings[ $attribute_name ] = $bindings[ $attribute_name ] ?? array( 'source' => 'core/pattern-overrides' );
}
@@ -351,6 +356,12 @@ private function process_block_bindings() {
$source_args = ! empty( $block_binding['args'] ) && is_array( $block_binding['args'] ) ? $block_binding['args'] : array();
$source_value = $block_binding_source->get_value( $source_args, $this, $attribute_name );
+ // Inner blocks are blocks, not an attribute.
+ if ( 'innerBlocks' === $attribute_name ) {
+ $this->replace_inner_blocks( $source_value );
+ continue;
+ }
+
// If the value is not null, process the HTML based on the block and the attribute.
if ( ! is_null( $source_value ) ) {
$computed_attributes[ $attribute_name ] = $source_value;
@@ -360,6 +371,148 @@ private function process_block_bindings() {
return $computed_attributes;
}
+ /**
+ * Replaces the inner blocks of the block with the ones a block bindings
+ * source supplies, before they render.
+ *
+ * The value is serialized block markup. An empty string leaves the block
+ * without inner blocks. Any other value, like the plain text of a field,
+ * leaves the block with its own inner blocks.
+ *
+ * @since 7.3.0
+ *
+ * @global WP_Embed $wp_embed WordPress Embed object.
+ *
+ * @param mixed $value The value of the source.
+ */
+ private function replace_inner_blocks( $value ) {
+ global $wp_embed;
+
+ if ( '' !== $value && ! ( is_string( $value ) && 'Default
' ) {
+ $bindings = $bindings ?? array( 'innerBlocks' => array( 'source' => self::SOURCE ) );
+ $markup = '';
+ $markup .= '' . $inner_html . '
';
+ $markup .= '';
+ $block = new WP_Block( parse_blocks( $markup )[0] );
+ return $block->render();
+ }
+
+ /**
+ * Returns the attributes the supplied blocks render with.
+ *
+ * @return array[] The attributes of each rendered block, in render order.
+ */
+ private function render_group_and_get_supplied_attributes() {
+ $attributes = array();
+ $capture = static function ( $parsed_block, $source_block, $parent_block ) use ( &$attributes ) {
+ if ( $parent_block ) {
+ $attributes[] = $parsed_block['attrs'];
+ }
+ return $parsed_block;
+ };
+ add_filter( 'render_block_data', $capture, 10, 3 );
+ $this->render_group();
+ remove_filter( 'render_block_data', $capture );
+ return $attributes;
+ }
+
+ /**
+ * @ticket 66415
+ */
+ public function test_value_replaces_inner_blocks() {
+ $this->value = 'One
Two
';
+ $output = $this->render_group();
+ $this->assertMatchesRegularExpression( '#>One
]*>Two
$#', $output );
+ $this->assertStringNotContainsString( 'Default', $output );
+ }
+
+ /**
+ * @ticket 66415
+ */
+ public function test_value_may_start_with_whitespace() {
+ $this->value = "\n\tOne
";
+ $this->assertStringContainsString( '>One', $this->render_group() );
+ }
+
+ /**
+ * @ticket 66415
+ */
+ public function test_empty_value_removes_inner_blocks() {
+ $this->value = '';
+ $output = $this->render_group();
+ $this->assertStringNotContainsString( 'assertStringContainsString( 'wp-block-group', $output );
+ }
+
+ /**
+ * @ticket 66415
+ */
+ public function test_without_value_keeps_own_inner_blocks() {
+ $this->assertStringContainsString( '>Default
', $this->render_group() );
+ }
+
+ /**
+ * @ticket 66415
+ */
+ public function test_plain_text_value_keeps_own_inner_blocks() {
+ $this->value = 'Plain text';
+ $output = $this->render_group();
+ $this->assertStringContainsString( '>Default', $output );
+ $this->assertStringNotContainsString( 'Plain text', $output );
+ }
+
+ /**
+ * @ticket 66415
+ */
+ public function test_value_does_not_become_an_attribute() {
+ $this->value = 'One
';
+ $block = new WP_Block( parse_blocks( '' )[0] );
+ $block->render();
+ $this->assertArrayNotHasKey( 'innerBlocks', $block->attributes );
+ }
+
+ /**
+ * @ticket 66415
+ */
+ public function test_default_pattern_overrides_binding_does_not_bind_inner_blocks() {
+ $requested = array();
+ $record = static function ( $value, $source_name, $source_args, $block_instance, $attribute_name ) use ( &$requested ) {
+ $requested[] = $attribute_name;
+ return $value;
+ };
+ add_filter( 'block_bindings_source_value', $record, 10, 5 );
+ $output = $this->render_group( array( '__default' => array( 'source' => 'core/pattern-overrides' ) ) );
+ remove_filter( 'block_bindings_source_value', $record );
+
+ $this->assertNotContains( 'innerBlocks', $requested );
+ $this->assertStringContainsString( '>Default', $output );
+ }
+
+ /**
+ * @ticket 66415
+ */
+ public function test_value_is_sanitized() {
+ $this->value = 'Safe
';
+ $output = $this->render_group();
+ $this->assertStringNotContainsString( '