Repository navigation
Block Bindings: Render innerBlocks bindings on the server - #85568
cbravobernal wants to merge 21 commits into
Conversation
🤖 PR meta 🤖🎉 PropsIf you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. Props Bot commandsYou can trigger Props Bot actions by commenting on this PR:
Updated as activity occurs, without notifying anyone named here. Add the ⚡ PerformanceShow the resultsClient side metrics exclude the server response time. front-end-block-theme
front-end-classic-theme
media-processing
media-upload
post-editor
site-editor
🏁 Flaky testsSome 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
|
d047fd6 to
b3462ce
Compare
b3462ce to
bba9715
Compare
There was a problem hiding this comment.
🟡 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
ab294f6 to
62c9907
Compare
9768551 to
7f4ddfd
Compare
c9be6da to
56757c0
Compare
…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>
49d8f80 to
ce47303
Compare
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>
da60765 to
922e29e
Compare
…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>
…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>
…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


Part of #61225. First of a series of small PRs, all behind the
gutenberg-inner-blocks-bindingsexperiment.What?
A block bindings source can now supply the blocks inside a Group or Column, on the frontend:
What the source returns decides what renders:
''Block markup is whatever the block parser reads as a block: the first delimiter, after any whitespace, must open a block.
What not
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
block_bindings_sanitize_inner_blocksfilter to keep it as supplied; custom CSS is removed either way.Testing Instructions
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
The templates bind a Group and keep a placeholder, which shows while the experiment is off:
A test page on the same site binds six blocks to the platform chart, to cover the shapes the placement has to handle:
</p>''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:
🤖 Generated with Claude Code