Skip to content

Block Bindings: Preserve nested inner blocks when binding rich text#12113

Open
cbravobernal wants to merge 10 commits into
WordPress:trunkfrom
cbravobernal:try/block-bindings-preserve-inner-blocks
Open

Block Bindings: Preserve nested inner blocks when binding rich text#12113
cbravobernal wants to merge 10 commits into
WordPress:trunkfrom
cbravobernal:try/block-bindings-preserve-inner-blocks

Conversation

@cbravobernal

@cbravobernal cbravobernal commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Trac ticket: https://core.trac.wordpress.org/ticket/65406

Builds on @Sauliusv's #12084, which adds core/list-item to the supported binding attributes. This PR supersedes it: it keeps that registration and adds the render fix that makes the binding non-destructive (nested lists were dropped otherwise), plus tests. #12084 is closed in favor of this PR.

The problem, in one example

A List Item keeps its text and a nested list inside the same <li>:

<li>My text
  <ul class="wp-block-list"><li>Nested item</li></ul>
</li>

If you bind the list item's content attribute to a source, Block Bindings replaces everything inside <li> — and the nested list disappears:

<!-- Bound value: "Updated text" -->
<li>Updated text</li>   <!-- ❌ the nested list is gone -->

This happens because WP_Block::replace_html() replaces the whole element matched by the attribute's selector, with no awareness that part of that element is produced by inner blocks.

The fix

Stop the replacement at the first inner block.

A block's own rich text always comes before its inner blocks, so we only need to know where the first inner block begins:

<li>My text<ul ...>...</ul></li>
        ↑ replace up to here, keep the rest

WP_Block::render() already assembles the output by concatenating inner_content (own HTML + each inner block's rendered output). So it can record, for free, the byte offset where each inner block starts. Those offsets are passed to replace_rich_text(), which clamps the replacement to the first inner block inside the selector. An inner block that begins exactly at the rich-text start offset is treated as a boundary.

Result:

<li>Updated text<ul class="wp-block-list"><li>Nested item</li></ul></li>   <!-- ✅ -->

Why this approach

  • Block-agnostic render path. The preservation logic contains zero list-item handling — it works from real block structure (the inner-block offsets), so it applies to any block that places an inner block inside a bound rich-text element. Enabling a specific block to bind rich text is then just a one-line entry in the supported-attributes map.
  • Correct, not heuristic. Because it uses the actual inner-block positions, it naturally tells apart raw <ul> typed into the text (replaced, it's part of the rich text) from a real nested block (preserved). No tag-name guessing.
  • No effect on existing blocks. Blocks without inner blocks pass an empty offset list, so the replacement behaves exactly as before.

What changed

  • src/wp-includes/block-bindings.php — register content as a bindable attribute for core/list-item, the first Core block to bind rich text that contains an inner block.
  • src/wp-includes/class-wp-block.php — record inner-block byte offsets during render(); pass them through replace_html() to replace_rich_text(), which stops at the first inner block inside the bound selector (boundary-inclusive), and document the $inner_block_offsets parameter.

Tests (tests/phpunit/tests/block-bindings/)

  • test_rich_text_binding_preserves_nested_inner_blocks (data-driven, 6 fixtures) — fallback text before a nested list, raw markup before a nested list, an inner block that starts exactly at the rich-text boundary, multibyte fallback with a formatted bound value, a deep nested list with a surrounding sibling, and an ordered nested list with attributes. Each asserts the replaced strings are gone, the nested inner-block content is preserved, the exact rendered markup, and that the bound content attribute is updated.
  • test_rich_text_binding_preserves_inner_blocks_for_any_block — an arbitrary registered block (rich text + an inner block in the same element) preserves its inner block too, proving there is no list-item special-casing in the render path.
  • test_replace_rich_text_stops_at_inner_block_offset (in wp-block-get-block-bindings-processor.php) — direct unit test of the new $inner_block_offsets parameter: the replacement clamps at the recorded offset, preserving the markup that follows it.
  • Plus a core/list-item case in the update-from-source data provider. The core/list-item registration itself is covered by that case end to end.

The render suite passes; the nested-list cases fail on trunk. --group block-bindings and --group blocks pass; PHPCS is clean.

Scope / notes

  • Today core/list-item is the only Core block whose bound rich-text selector contains an inner block. Other Core blocks with multiple rich-text attributes (table, pullquote, file, quote, gallery) are not registered as bindable; and where they have inner blocks, the rich-text elements sit outside the inner-block content anyway, so enabling them later would not exercise different behavior.
  • No Core block binds two rich-text attributes in the same element that also contains inner blocks, so offset invalidation across successive replacements isn't exercised; noted here in case such a block is added later.

Context

Unblocks the elegant version of the Gutenberg plugin feature in WordPress/gutenberg#78991, which currently needs a large compat workaround to preserve nested lists. With this change, that workaround can be removed for WordPress 7.1+.

Props

Original list-item supported-attribute registration and the companion Gutenberg work by @Sauliusv (#12084, WordPress/gutenberg#78991).

Use of AI Tools

Both Claude and Codex were used to help author the render fix and the expanded PHPUnit coverage, and to draft this description. All changes were reviewed locally and the full block-bindings/blocks suites were run before pushing.

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@cbravobernal cbravobernal force-pushed the try/block-bindings-preserve-inner-blocks branch from 1a18473 to d095e16 Compare June 7, 2026 09:38
@cbravobernal cbravobernal self-assigned this Jun 8, 2026
cbravobernal added a commit to cbravobernal/gutenberg that referenced this pull request Jun 8, 2026
The compat code in this PR is a plugin-only shim that preserves nested
lists when binding list item content. It is not backported to Core: the
Core fix is a different, general implementation in
WordPress/wordpress-develop#12113, after which this workaround can be
removed. Classify the PR with the `No Core Sync Required` label instead
of a backport-changelog entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cbravobernal cbravobernal force-pushed the try/block-bindings-preserve-inner-blocks branch from 4f3c8d1 to b03fe11 Compare June 9, 2026 15:19
cbravobernal added a commit to cbravobernal/gutenberg that referenced this pull request Jun 9, 2026
The compat code in this PR is a plugin-only shim that preserves nested
lists when binding list item content. It is not backported to Core: the
Core fix is a different, general implementation in
WordPress/wordpress-develop#12113, after which this workaround can be
removed. Classify the PR with the `No Core Sync Required` label instead
of a backport-changelog entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cbravobernal cbravobernal force-pushed the try/block-bindings-preserve-inner-blocks branch from 291db7d to 2f46385 Compare June 9, 2026 22:35
cbravobernal and others added 7 commits June 10, 2026 12:17
When a block stores rich text and inner blocks inside the same selector
element (for example a List block nested inside a List Item), binding the
rich-text attribute replaced the entire element contents and dropped the
nested inner blocks.

Record the byte offset where each inner block's rendered output begins
during render, and stop the rich-text replacement at the first inner
block found inside the selector. A block's own rich text always precedes
its inner blocks, so only that rich text is replaced while inner block
markup is preserved. Blocks without inner blocks are unaffected.

Add render tests covering nested list preservation and raw-markup
replacement before a delimiter-backed inner block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sts.

Add `core/list-item => content` to the supported attributes map so the
List Item block binds its rich text natively, removing the test-only
`block_bindings_supported_attributes` filter from the coverage.

Treat an inner block that begins exactly at the rich-text start offset as
a boundary (`>=`) in `WP_Block::replace_html`, and document the
`$inner_block_offsets` parameter.

Rework the render tests into a data-driven suite covering fallback text,
raw markup, multibyte content, ordered lists with attributes, and
surrounding siblings, and assert the bound content attribute is updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `@ticket 65406` to the List Item content binding and inner-block
preservation tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the `$inner_block_offsets` default and align the parameter
description in `replace_html()`, and add `@since 7.1.0` notes for the
new parameter and the inner-block-preserving render behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Only collect inner-block offsets when the block has bound attributes,
  avoiding a strlen() per inner block on every other render.
- Clear the offsets after the first replacement, since each replacement
  can change the content length and invalidate them; later bindings fall
  back to offset-free replacement instead of clamping at a stale byte.
- Note why the offset lower bound is inclusive of the start position.
- Reword the render() @SInCE note (all inner blocks, not only nested).
- Use the SOURCE_LABEL constant for the new sources and make it a string,
  and document the test whitespace normalizer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Note that any future replacement API must preserve already-rendered
inner block markup verbatim, so the byte-offset splice is not naively
swapped for a re-serializing call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the missing `@since 7.1.0` changelog entry to
`get_block_bindings_supported_attributes()`, drop the supports test that
duplicated the existing data-provider coverage, and add a direct unit test
for the new `$inner_block_offsets` parameter of `replace_rich_text()`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cbravobernal cbravobernal force-pushed the try/block-bindings-preserve-inner-blocks branch from 2f46385 to 9cf3cf6 Compare June 10, 2026 10:17
@cbravobernal cbravobernal marked this pull request as ready for review June 10, 2026 11:07
@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props cbravobernal, jonsurrell.

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

cbravobernal and others added 2 commits June 10, 2026 13:43
Replace the custom inter-tag whitespace normalizer with the framework's
assertEqualHTML() and exact expected markup, including the newlines the
block serializer produces. The assertions now cover the rendered output
byte for byte while failing with a readable tree diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pattern overrides expand the `__default` binding into computed attributes
that include the rewritten `metadata` attribute alongside the bound rich
text. The render loop cleared the inner-block offsets after every
`replace_html()` call, so the no-op `metadata` replacement invalidated
them and the `content` replacement that followed fell back to the
destructive whole-element path, dropping nested inner blocks.

Clear the offsets only when a replacement actually modified the markup,
and add a pattern overrides regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sirreal

sirreal commented Jun 10, 2026

Copy link
Copy Markdown
Member

Code review

Note: This is an automated review generated by an AI agent (Claude Code, model Fable 5 / claude-fable-5), posted on behalf of @sirreal.

No high-confidence issues found. The core mechanism — recording byte offsets of inner blocks during render, clamping rich-text replacement at the first offset inside the matched element, and invalidating offsets once a replacement changes the markup — was checked for off-by-one errors, coordinate-space mismatches, stale-offset bugs, and regressions against the history of replace_html/replace_rich_text, and held up.

Two minor, non-blocking observations:

  1. The replace_rich_text() docblock describes $inner_block_offsets as "Byte offsets in the source HTML", but the offsets are into the assembled $block_content string the processor was created from (as the corresponding comment in render() correctly states). Worth aligning the wording to avoid confusion with the serialized post content.

* @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, $inner_block_offsets = array() ) {

  1. In test_rich_text_binding_preserves_inner_blocks_for_any_block(), the remove_filter()/unregister_block_type() cleanup runs inline after $block->render(). If render() throws, test/rich-text-with-inner-blocks leaks into subsequent tests. Other test classes (e.g. tests/blocks/render.php) do this cleanup in tear_down().

remove_filter( 'block_bindings_supported_attributes', $supported_attributes_filter, 10 );
unregister_block_type( 'test/rich-text-with-inner-blocks' );

🤖 Generated with Claude Code (model: claude-fable-5)

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@sirreal sirreal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not very familiar with block bindings, but I'll try to provide helpful feedback.

Is it possible somehow to produce a list item child in the replacement? As noted in the comment, that difficult is why set_inner_html does not yet exist, because if an <li is inserted in <li>[[set this inner HTML]] <ul><li>nested</li></ul></li>, then the outside HTML structure is altered and the contract is broken. That's the main danger of working with something like an LI, <li><li> is two adjacent LI, not nested.

Comment on lines +482 to +491
* 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this always hold? Specifically:

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.

Another way we might do this is to find all the text nodes from the block start to the first inner block and that becomes the replacement range. We could use a Tag Processor on that range to ensure that everything is, in fact, text.

@cbravobernal cbravobernal Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For core/list-item, yes. The block renders its innerContent in order: own rich-text first, then the inner-block placeholders. The offsets are recorded in that same render loop, so the first offset is probably where the rich text ends.

For an arbitrary block or manual markup, it's not guaranteed. I'll add a test.

On the Tag Processor idea

On finding the range by walking text nodes: the range isn't always text. It can contain raw block markup that should be replaced, right next to a real inner block that shouldn't, and they look identical once rendered. The offset is the only thing that knows which <ul> came from an actual inner block.

Still, I would need to add a test to be really sure about it.

@cbravobernal

Copy link
Copy Markdown
Contributor Author

I'm not very familiar with block bindings, but I'll try to provide helpful feedback.

Is it possible somehow to produce a list item child in the replacement? As noted in the comment, that difficult is why set_inner_html does not yet exist, because if an <li is inserted in <li>[[set this inner HTML]] <ul><li>nested</li></ul></li>, then the outside HTML structure is altered and the contract is broken. That's the main danger of working with something like an LI, <li><li> is two adjacent LI, not nested.

Thanks for the review @sirreal ! You may not be block bindings familiar, but still a WordPress - HTML API expert 😄 .

Yes, it is possible. Is already also possible in paragraphs, headings and button, which is already happening since 6.5. I guess we consider block bindings a developer tool, and it's developer's risk to add unbalanced tags that could break different blocks.

Maybe is something we should work on on a different PR, so that way is only a fix for all blocks with this issue rather than coupling to this list-item only problem.

Pins the documented boundary of the inner-block-offset replacement: when
a List Item is authored with the nested list before its text, the bound
value is written ahead of the inner block and the trailing rich text is
left in place. The nested inner block is preserved and the markup stays
structurally valid, so the failure mode is incomplete replacement rather
than broken structure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cbravobernal cbravobernal force-pushed the try/block-bindings-preserve-inner-blocks branch from 4186b05 to 48a00b5 Compare June 11, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants