Skip to content

Block Bindings: Render innerBlocks bindings on the server - #85568

Open
cbravobernal wants to merge 21 commits into
trunkfrom
add/61225-inner-blocks-bindings-server
Open

cbravobernal wants to merge 21 commits into
trunkfrom
add/61225-inner-blocks-bindings-server

Conversation

@cbravobernal

@cbravobernal cbravobernal commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Part of #61225. First of a series of small PRs, all behind the gutenberg-inner-blocks-bindings experiment.

What?

A block bindings source can now supply the blocks inside a Group or Column, on the frontend:

<!-- wp:group {"metadata":{"bindings":{"innerBlocks":{"source":"my/source"}}}} -->
<div class="wp-block-group"><!-- wp:paragraph --><p>Shown when the source has no value</p><!-- /wp:paragraph --></div>
<!-- /wp:group -->

What the source returns decides what renders:

The source returns The Group renders
Block markup Those blocks
'' Nothing inside
Anything else Its own blocks

Block markup is whatever the block parser reads as a block: the first delimiter, after any whitespace, must open a block.

What not

  • The editor: it still shows the Group's own blocks, not the source's.
  • A UI to create the binding: for now it has to be written by hand in the markup.
  • Pattern-overrides support: synced patterns can't use it yet.
  • Automatic excerpts: they still show the Group's own blocks. excerpt_remove_blocks() walks the Group's parsed children without rendering it, and has no hook for this. The fix goes in Core's excerpt walker, in the Core PR, not in a copy of it here.

Why?

Today a source can fill an attribute, like a paragraph's text, but not a list of blocks. This lets a template show things like "the expansions of this game" or "the files attached to this post" as real blocks, and it's the base for free areas in synced patterns.

How?

What happens at render

  1. Core's WP_Block::render() resolves bindings first, through process_block_bindings(). It gets the source's value with the block's final context, which is how a binding inside a Query Loop knows its postId.
  2. The value is sanitized with wp_kses_post(). It never went through post-save sanitizing, and whoever wrote it may not have unfiltered_html. kses also cleans the block attributes inside the comments. A source whose markup comes from code, not from users, can return false from the block_bindings_sanitize_inner_blocks filter to keep it as supplied; custom CSS is removed either way.
  3. URLs on their own line get embedded (autoembed), as they do in post content.
  4. The markup is parsed into blocks, and their custom CSS (style.css) is removed, because only users who can edit CSS may add it, and the value's author is unknown.
  5. The blocks are placed:
    • If the Group had blocks of its own, the new ones take their place. The HTML around them, including a legacy inner container, stays.
    • If it had none, there's no slot. The HTML API (WP_HTML_Processor) finds the closing tag of the Group's wrapper, and the blocks go just before it. That's the loop you stepped through in Xdebug.
    • If the wrapper can't be found, the value is skipped and the Group renders as before.
  6. The Group then renders normally, with its new children. While they render, a binding that repeats a request already rendering is skipped and the block keeps its own blocks. A request is the source, the arguments of the binding, the context values the source uses, and the attributes of the block. This also covers content that loads as it renders, like a Navigation menu, so sources that return each other's markup, directly or through such content, can't loop forever. Nested bindings to another source, other arguments or other context resolve, and so do sibling bindings, one after the other.

Testing Instructions

  1. Enable "Inner blocks bindings" in Gutenberg → Experiments.
  2. Register a source, for example in a mu-plugin:
add_action( 'init', function () {
	register_block_bindings_source( 'demo/inner-blocks', array(
		'label'              => 'Demo',
		'get_value_callback' => function ( $args ) {
			return '<!-- wp:paragraph --><p>' . esc_html( $args['text'] ?? '' ) . '</p><!-- /wp:paragraph -->';
		},
	) );
} );
  1. In a post, open the code editor and paste:
<!-- wp:group {"metadata":{"bindings":{"innerBlocks":{"source":"demo/inner-blocks","args":{"text":"From the source"}}}}} -->
<div class="wp-block-group"><!-- wp:paragraph --><p>Own content</p><!-- /wp:paragraph --></div>
<!-- /wp:group -->
  1. View the post: it shows "From the source", not "Own content".
  2. Turn the experiment off: it shows "Own content" again.

Tests: phpunit --filter Tests_Block_Bindings_Inner_Blocks.

A real example

A video game library plugin on a test site binds three Groups to its own sources: a game's expansions, as a List block, and two charts of finished games, as Custom HTML blocks. Each source checks the attribute it's asked for, and only returns block markup for innerBlocks:

The sources
register_block_bindings_source( 'vg-library/game', array(
	'label'              => __( 'Game details', 'vg-library' ),
	'uses_context'       => array( 'postId' ),
	'get_value_callback' => static function ( array $args, WP_Block $block, string $attribute = '' ): string {
		$id = (int) ( $block->context['postId'] ?? get_the_ID() );
		if ( 'innerBlocks' === $attribute ) {
			return self::game_blocks( $args['key'] ?? '', $id ); // Block markup, or '' for none.
		}
		return self::fact( $args['key'] ?? '', $id );
	},
) );

register_block_bindings_source( 'vg-library/stats', array(
	'label'              => __( 'Library stats', 'vg-library' ),
	'get_value_callback' => static function ( array $args, WP_Block $block, string $attribute = '' ): string {
		$value = self::stats()[ $args['key'] ?? '' ] ?? '';
		// Charts are block markup, for inner blocks bindings only.
		$is_markup = str_starts_with( $value, '<!-- wp:' );
		if ( 'innerBlocks' === $attribute ) {
			return $is_markup ? $value : '';
		}
		return $is_markup ? '' : esc_html( $value );
	},
) );

// The expansions of a game, as a List block.
private static function game_blocks( string $key, int $id ): string {
	if ( 'expansions' !== $key ) {
		return '';
	}
	$items = '';
	foreach ( get_posts( array( 'post_type' => 'vg_game', 'numberposts' => -1, 'meta_key' => 'parent_game', 'meta_value' => $id ) ) as $expansion ) {
		$items .= sprintf( '<!-- wp:list-item --><li><a href="%s">%s</a></li><!-- /wp:list-item -->', esc_url( get_permalink( $expansion ) ), esc_html( get_the_title( $expansion ) ) );
	}
	return $items ? '<!-- wp:list --><ul class="wp-block-list">' . $items . '</ul><!-- /wp:list -->' : '';
}

The templates bind a Group and keep a placeholder, which shows while the experiment is off:

<!-- wp:group {"metadata":{"bindings":{"innerBlocks":{"source":"vg-library/stats","args":{"key":"completed_per_platform"}}}}} -->
<div class="wp-block-group"><!-- wp:paragraph --><p>The chart shows here.</p><!-- /wp:paragraph --></div>
<!-- /wp:group -->

A test page on the same site binds six blocks to the platform chart, to cover the shapes the placement has to handle:

# Bound block Renders
1 Group with blocks of its own The chart, in place of them
2 Empty Group The chart, inside it
3 Legacy Group, with text after its inner container The chart, after the text: a Group without blocks of its own gets them before its closer
4 Group followed by a comment that contains </p> The chart, inside the Group
5 Empty Column The chart, inside it
6 Group whose source returns '' Nothing inside

Cases 3 and 4 are why the placement uses the HTML API: searching the markup for the last closing tag put the chart inside the paragraph in case 3, and inside the comment in case 4, where it never showed.

Screenshots or screencast

The test page from the example above:

Screenshot 2026-10-28 at 15 55 55 Screenshot 2026-10-28 at 15 56 04 Screenshot 2026-10-28 at 15 56 07

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

Preview in WordPress Playground

🎉 Props

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: cbravobernal <cbravobernal@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

Updated as activity occurs, without notifying anyone named here. Add the props-bot label to refresh.

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric 7669d8a trunk % Change
timeToFirstByte 34.85 ms +20.52% -3.87% 33.75 ms +13.78% -1.48% 3.26%
largestContentfulPaint 60 ms +10% -3.33% 56 ms +7.14% -3.57% 7.14%
lcpMinusTtfb 26.45 ms +4.54% -17.96% 20.6 ms +10.44% -8.74% 28.4%
wpBeforeTemplate 18.28 ms +22.37% -2.08% 17.73 ms +11.96% -2.14% 3.1%
wpTemplate 13.52 ms +12.65% -5.77% 13.07 ms +5.13% -2.07% 3.44%
wpTotal 31.86 ms +21.59% -3.7% 30.79 ms +14.68% -1.56% 3.48%
wpMemoryUsage 7.72 MB +0% -0% 7.68 MB +0% -0% 0.49%
wpDbQueries 16 +0% -0% 16 +0% -0% 0%

front-end-classic-theme

Metric 7669d8a trunk % Change
timeToFirstByte 29 ms +6.21% -1.38% 28.55 ms +10.51% -2.1% 1.58%
largestContentfulPaint 56 ms +0% -3.57% 52 ms +7.69% -0% 7.69%
lcpMinusTtfb 26.25 ms +3.05% -9.71% 24.05 ms +4.37% -2.49% 9.15%
wpBeforeTemplate 16.19 ms +9.88% -1.73% 15.85 ms +4.04% -1.14% 2.15%
wpTemplate 10.22 ms +4.89% -2.15% 10.18 ms +2.75% -3.05% 0.39%
wpTotal 26.47 ms +6.38% -1.81% 26.1 ms +6.63% -2.57% 1.42%
wpMemoryUsage 6.41 MB +0% -0% 6.37 MB +0% -0% 0.6%
wpDbQueries 13 +0% -0% 13 +0% -0% 0%

media-processing

Metric 7669d8a trunk % Change
mediaProcessingJpeg 225.03 ms +1.31% -1.27% 222.68 ms +0.58% -0.35% 1.06%
mediaProcessingAvif 3763.47 ms +1% -0.23% 3608.82 ms +0.61% -0.94% 4.29%
mediaProcessingJpegToAvif 2571.75 ms +0.46% -0.63% 2534.83 ms +0.46% -0.26% 1.46%

media-upload

Metric 7669d8a trunk % Change
jpegUploadProcessing 1444.21 ms +0.94% -0.68% 1421.31 ms +0.3% -0.96% 1.61%
pngUploadProcessing 187.98 ms +2.8% -1.92% 179.14 ms +2.22% -2.98% 4.93%
largeJpegUploadProcessing 1432.6 ms +1.13% -1.17% 1427.99 ms +2.45% -0.55% 0.32%
multipleImageUploadProcessing 1487.94 ms +0.41% -0.42% 1486.18 ms +1.52% -1.08% 0.12%

post-editor

Metric 7669d8a trunk % Change
serverResponse 292.42 ms +2.96% -6.64% 285.54 ms +14.37% -5.19% 2.41%
firstPaint 317.38 ms +9.54% -6.95% 307.06 ms +10.07% -4.64% 3.36%
domContentLoaded 573.33 ms +11.13% -1.41% 588.72 ms +2.26% -0.66% -2.61%
loaded 574.15 ms +11.08% -1.43% 589.4 ms +2.27% -0.64% -2.59%
firstContentfulPaint 326.75 ms +17.13% -1.17% 340.46 ms +1.54% -3.06% -4.03%
firstBlock 1528.06 ms +10.75% -1.94% 1523.06 ms +2.42% -0.76% 0.33%
type 14.62 ms +6.91% -7.18% 15.71 ms +5.03% -12.35% -6.94%
typeWithoutInspector 15.28 ms +6.22% -15.31% 15.3 ms +10.13% -5.56% -0.13%
typeWithTopToolbar 19.89 ms +5.23% -13.78% 19.65 ms +6.82% -3.46% 1.22%
typeContainer 6.96 ms +16.67% -4.89% 7.26 ms +4.82% -7.3% -4.13%
focus 84.17 ms +3.59% -2.99% 86.98 ms +8.54% -5.14% -3.23%
firstFocus 165.93 ms +0% -0% 180.84 ms +0% -0% -8.24%
selectAll 269.55 ms +0.71% -2.16% 263.34 ms +2.08% -3.04% 2.36%
undo 22.48 ms +2.76% -3.91% 22.82 ms +8.02% -3.42% -1.49%
listViewOpen 57.46 ms +17.44% -13.1% 56.91 ms +2.04% -5.45% 0.97%
inserterOpen 31.28 ms +8.92% -7.29% 31.62 ms +11.07% -5.12% -1.08%
inserterHover 9.99 ms +4% -5.11% 9.35 ms +9.84% -4.39% 6.84%
inserterSearch 7.33 ms +4.77% -7.23% 7.6 ms +6.32% -2.76% -3.55%
loadPatterns 498.96 ms +3.07% -3.87% 511.91 ms +1.69% -4.15% -2.53%
wpTotal 284.78 ms +3.01% -6.79% 277.72 ms +14.45% -5.44% 2.54%
wpMemoryUsage 14.77 MB +0% -0% 14.73 MB +0% -0% 0.31%
wpDbQueries 55 +0% -0% 55 +0% -0% 0%

site-editor

Metric 7669d8a trunk % Change
serverResponse 479.51 ms +0.46% -1.97% 471.97 ms +0.88% -2.89% 1.6%
firstPaint 456.04 ms +0.68% -14.69% 418.19 ms +11.81% -5.29% 9.05%
domContentLoaded 834.71 ms +0.74% -1.02% 833.92 ms +0.81% -2.19% 0.09%
loaded 835.68 ms +0.77% -0.99% 834.93 ms +0.79% -2.21% 0.09%
firstContentfulPaint 458.92 ms +3.25% -1.16% 466.18 ms +2.75% -1.68% -1.56%
firstBlock 2568.64 ms +5.75% -2.01% 2504.09 ms +1.34% -0.6% 2.58%
type 17.77 ms +6.02% -8.84% 18.02 ms +2.94% -8.05% -1.39%
navigate 120.7 ms +0.75% -9.05% 116.47 ms +8.12% -4.95% 3.63%
loadPatterns 1345.55 ms +6.75% -5.84% 1410.04 ms +4.92% -16.83% -4.57%
loadPages 943.17 ms +3.35% -0.63% 944.82 ms +4.16% -0.95% -0.17%
wpTotal 470.05 ms +0.47% -2% 462.39 ms +0.93% -2.91% 1.66%
wpMemoryUsage 12.79 MB +0% -0% 12.74 MB +0% -0% 0.36%
wpDbQueries 45 +0% -0% 45 +0% -0% 0%

12ec391 Run

🏁 Flaky tests

Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

List in /test/e2e/specs/editor/blocks/query.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 22
+ Received  +  1

  Array [
    Object {
-     "innerBlocks": Array [
-       Object {
-         "innerBlocks": Array [
-           Object {
-             "name": "core/post-title",
-           },
-           Object {
-             "name": "core/post-featured-image",
-           },
-           Object {
-             "name": "core/post-excerpt",
-           },
-           Object {
-             "name": "core/separator",
-           },
-           Object {
-             "name": "core/post-date",
-           },
-         ],
-         "name": "core/post-template",
-       },
-     ],
+     "innerBlocks": Array [],
      "name": "core/query",
    },
  ]

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/query.spec.js:88:42
Page List converts to Navigation Links with entity bindings in /test/e2e/specs/editor/blocks/navigation.spec.js, passed after 1 failed attempt.
Error: expect(locator).toContainText(expected) failed

Locator: getByRole('region', { name: 'Editor settings' }).getByRole('tabpanel', { name: 'Content' }).getByRole('button', { name: /Link to:/ })
Expected substring: "test-page-1"
Received string:    "Link to:Cat/catPagePublished"
Timeout: 5000ms

Call log:
  - Expect "toContainText" getByRole('region', { name: 'Editor settings' }).getByRole('tabpanel', { name: 'Content' }).getByRole('button', { name: /Link to:/ }) with timeout 5000ms
  - waiting for getByRole('region', { name: 'Editor settings' }).getByRole('tabpanel', { name: 'Content' }).getByRole('button', { name: /Link to:/ })
    14 × locator resolved to <button type="button" aria-expanded="false" aria-haspopup="dialog" class="components-button link-preview-button is-next-40px-default-size is-secondary">…</button>
       - unexpected value "Link to:Cat/catPagePublished"

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/navigation.spec.js:1949:31
does not select blocks in the canvas preview when editing from the sidebar (Site Editor v2) in /test/e2e/specs/site-editor/navigation-sidebar-list-view.spec.js, passed after 1 failed attempt.
Error: expect(locator).toBeFocused() failed

Locator: getByRole('combobox', { name: 'Search or type URL' })
Expected: focused
Timeout: 5000ms
Error: element(s) not found

Call log:
  - Expect "toBeFocused" getByRole('combobox', { name: 'Search or type URL' }) with timeout 5000ms
  - waiting for getByRole('combobox', { name: 'Search or type URL' })

    at LinkControl.useLinkControlSearch (/home/runner/work/gutenberg/gutenberg/test/e2e/specs/site-editor/navigation-sidebar-list-view.spec.js:416:47)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/site-editor/navigation-sidebar-list-view.spec.js:179:21
inserts a block in proper place after having clicked `Browse All` from inline inserter in /test/e2e/specs/editor/various/inserting-blocks.spec.js, passed after 1 failed attempt.
TimeoutError: locator.click: Timeout 10000ms exceeded.
Call log:
  - waiting for getByRole('button', { name: 'Add block' })
    - locator resolved to <button type="button" aria-pressed="false" aria-haspopup="true" aria-expanded="false" aria-label="Add block" class="components-button block-editor-inserter__toggle is-next-40px-default-size has-icon">…</button>
  - attempting click action
    - waiting for element to be visible, enabled and stable
    - element is not stable
  - retrying click action
    - waiting for element to be visible, enabled and stable
  - element was detached from the DOM, retrying

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/inserting-blocks.spec.js:557:5

12ec391 Run

@cbravobernal cbravobernal self-assigned this Oct 26, 2026
@cbravobernal
cbravobernal force-pushed the add/61225-inner-blocks-bindings-server branch from d047fd6 to b3462ce Compare October 26, 2026 16:00
@cbravobernal cbravobernal added the No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core label Oct 26, 2026
@cbravobernal
cbravobernal marked this pull request as ready for review October 26, 2026 16:13
@cbravobernal
cbravobernal force-pushed the add/61225-inner-blocks-bindings-server branch from b3462ce to bba9715 Compare October 26, 2026 16:14
@cbravobernal
cbravobernal requested a balanced review from Copilot October 27, 2026 09:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Source sanitization bypasses custom-CSS restrictions, and replacement placement breaks legacy Group inner containers.

2 open issues
What changed in this PR

Adds experimental server-side innerBlocks bindings for Group and Column blocks, laying the groundwork for source-driven block content while leaving editor behavior unchanged.

Changes:

  • Replaces inner blocks with sanitized source markup, supporting empty values and fallback content.
  • Removes nested bindings that could cause recursive expansion.
  • Adds experiment registration, conditional loading, and PHP tests.
File Description
phpunit/​experimental/​block-bindings-inner-blocks-test.php Tests replacement, fallback, sanitization, and recursion prevention.
phpunit/​bootstrap.php Enables the experiment for PHP tests.
lib/​load.php Loads the implementation when the experiment is enabled.
lib/​experimental/​experiments/​load.php Registers the Inner blocks bindings experiment.
lib/​experimental/​block-bindings-inner-blocks.php Implements source-driven inner-block rendering.

Review effort: Balanced

@cbravobernal
cbravobernal force-pushed the add/61225-inner-blocks-bindings-server branch 2 times, most recently from ab294f6 to 62c9907 Compare October 28, 2026 10:39
@cbravobernal
cbravobernal requested a balanced review from Copilot October 28, 2026 11:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the experimental server-side innerBlocks bindings.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@cbravobernal
cbravobernal force-pushed the add/61225-inner-blocks-bindings-server branch 2 times, most recently from 9768551 to 7f4ddfd Compare October 28, 2026 15:08
@cbravobernal
cbravobernal force-pushed the add/61225-inner-blocks-bindings-server branch 3 times, most recently from c9be6da to 56757c0 Compare October 29, 2026 14:13
cbravobernal added a commit to cbravobernal/wordpress-develop that referenced this pull request Oct 29, 2026
…n 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 <noreply@anthropic.com>
@cbravobernal cbravobernal added [Type] Experimental Experimental feature or API. and removed No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core labels Oct 29, 2026
@cbravobernal
cbravobernal force-pushed the add/61225-inner-blocks-bindings-server branch from 49d8f80 to ce47303 Compare October 30, 2026 12:52
cbravobernal and others added 14 commits November 4, 2026 17:52
Behind the gutenberg-inner-blocks-bindings experiment, a block bindings
source can supply the inner blocks of a Group or Column block through
metadata.bindings.innerBlocks. The value is block markup, or '' for no
inner blocks; anything else keeps the block's own inner blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The blocks a source supplies lose every innerBlocks binding, whatever its
source, so that two sources whose values bind each other's render once.
Adds tests for that, for attribute bindings to other sources being kept,
and for block attributes inside a value being sanitized.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…acy Group inner containers

The markup a source supplies skips the save-time filter that removes
`style.css` for users without `edit_css`, so it could add CSS to the
page. Strip it, including state-nested CSS, on every render, since who
supplied the value is unknown.

Insert the supplied blocks where the block's own inner blocks were, so
a legacy Group keeps them inside its `wp-block-group__inner-container`,
and handle an empty legacy Group too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep the value when a Group mentions the inner container class without
having one, instead of dropping it, and skip blocks built without
`innerContent` instead of failing. Cover placement by slot and nested
custom CSS with tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `block_bindings_supported_attributes` filter also feeds the editor's
`__experimentalBlockBindingsSupportedAttributes` setting, so Group and
Column offered an `innerBlocks` row in the Attributes panel and "Enable
overrides" in synced patterns before the editor supports the binding.
Remove `innerBlocks` from that setting until it does.

Only strip `innerBlocks` bindings from the blocks a source supplies:
attribute bindings to the same source can't loop, so keep them.

Assert that a block with no markup to place the value in renders nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Group or Column without inner blocks of its own has no placeholder to
put the bound blocks in, so the position was found by searching the
markup for the last closer. That put them inside the wrong element when
markup followed the inner container of a legacy Group, or when the
wrapper carried the inner container's class name.

Find the matching closer of the wrapper, or of the legacy inner container,
with WP_HTML_Tag_Processor instead. The inner container is detected the
way wp_restore_group_inner_container() does, and the closer is matched
the way the Interactivity API's directives processor does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A block with inner blocks of its own keeps its inner content as it is:
only the slots of its inner blocks are replaced, so no markup is rebuilt.

A block without any is split with WP_HTML_Processor, token by token as
WP_HTML_Processor::serialize() does, instead of counting tags by name
with the Tag Processor and reading byte offsets from its bookmarks. The
processor knows where an element ends even when its closer is implied
or a stray closer is ignored, and the split never leaves the function
as an offset. Markup the processor can't parse leaves the block with its
own inner blocks.

Embeds in the value still run through autoembed(), but the [embed]
shortcode no longer does, as shortcodes are on their way out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Group without inner blocks of its own now gets the supplied blocks
before the closer of its wrapper, without looking for the inner container
of a legacy Group first.

That rule only mattered for an empty Group still saved with an inner
container, which the Group block stopped writing in WordPress 5.8 and
migrates away when edited, and which also needs an innerBlocks binding
added by hand. Legacy Groups with inner blocks of their own keep getting
the supplied blocks in place of those, inside the inner container. On
classic themes, wp_restore_group_inner_container() adds the inner
container to modern Groups after the blocks are placed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…no blocks

The property is declared as a WP_Block_List, which PHPStan flags in the Core
backport when an empty array is assigned instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bound inner blocks render inside `do_blocks()`, after `WP_Embed` has run its
`[embed]` shortcode on the content, like the synced pattern block does for
its own content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cbravobernal and others added 5 commits November 6, 2026 12:47
…ed blocks render.

Removing innerBlocks bindings from the parsed value only covered blocks
in the value itself. A block that loads content as it renders, like a
Navigation block's menu, could bring back a Group bound to the same
source and render forever.

While a block renders the inner blocks a source supplied, innerBlocks
is now left out of the attributes Group and Column support, so Core
skips the binding without calling its source. The block is tracked
until its own render_block filter runs, so sibling bindings still
resolve one after the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The value was only taken as block markup when it started with the
literal `<!-- wp:`, which is narrower than the block parser: a newline
or tab after `<!--` still opens a block for parse_blocks(), but made the
Group fall back to its own inner blocks.

WP_Block_Processor now skips any whitespace-only HTML and requires the
first delimiter to open a block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ordPress/gutenberg into add/61225-inner-blocks-bindings-server
…g request, and let trusted sources skip kses.

While a block rendered supplied inner blocks, no descendant could get its
inner blocks from any source. A source couldn't supply a Group bound to
another source, or to the same source with other arguments or context.

A binding is now skipped only while the inner blocks a source supplied
for the same request render: the same source, arguments, and the context
the source uses. That still stops sources whose values bind each other's,
directly or through content that loads as it renders, while nested
bindings that ask something else resolve. A host that stops rendering
with an exception is forgotten along with the next host that finishes.

The new `block_bindings_sanitize_inner_blocks` filter lets a source whose
markup comes from trusted code keep it as supplied, instead of having it
sanitized as the post content of a user without `unfiltered_html`.
Custom CSS is removed either way.

The tests no longer assert with regular expressions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
cbravobernal added a commit to cbravobernal/wordpress-develop that referenced this pull request Nov 10, 2026
…g request, and let trusted sources skip kses.

Ports the latest changes of the Gutenberg PR:

- A value is block markup when the first delimiter WP_Block_Processor
  finds, after whitespace-only HTML, opens a block, which matches what
  parse_blocks() reads as a block.
- Instead of removing the innerBlocks bindings of every supplied block,
  a binding is skipped, without asking its source, only while the inner
  blocks a source supplied for the same request render: the same source,
  arguments, and the context the source uses. Sources whose values bind
  each other's, directly or through content that loads as it renders,
  still can't render forever, and nested bindings that ask something
  else resolve.
- The `block_bindings_sanitize_inner_blocks` filter lets a source whose
  markup comes from trusted code keep it as supplied. Custom CSS is
  removed either way.
- The tests no longer assert with regular expressions.

See WordPress/gutenberg#85568.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
cbravobernal and others added 2 commits November 10, 2026 18:39
…value, and key requests by attributes too.

A block whose inner content held only block slots, with no wrapper
markup, crashed when its source supplied no inner blocks: the empty inner
content never reached the block instance, which rendered its old slots
against an empty list of inner blocks. The instance now gets its inner
content and HTML directly.

The request a binding is skipped for also includes the attributes of the
block, since a source may read them, like the pattern overrides source
reads the name of the block.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCeAiscBeSgrKjrPgYBr1b
…ders a Group unchanged.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MCeAiscBeSgrKjrPgYBr1b

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Block bindings [Type] Enhancement A suggestion for improvement. [Type] Experimental Experimental feature or API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants