Opened 6 weeks ago
Closed 4 weeks ago
#65406 closed feature request (fixed)
Block Bindings: support List Item content binding without dropping nested inner blocks
| Reported by: | sauliusv | Owned by: | cbravobernal |
|---|---|---|---|
| Priority: | normal | Milestone: | 7.1 |
| Component: | Editor | Version: | |
| Severity: | normal | Keywords: | has-patch has-unit-tests |
| Cc: | Focuses: |
Description (last modified by )
Add Block Bindings support for the List Item block's content attribute.
This needs more than registering the attribute, because a List Item stores its rich text and a nested List in the same <li>:
<!-- wp:list-item --> <li>My text <!-- wp:list --> <ul class="wp-block-list"><!-- wp:list-item --> <li>Nested item</li> <!-- /wp:list-item --></ul> <!-- /wp:list --></li> <!-- /wp:list-item -->
WP_Block::replace_html() replaces everything between the selector's opening and closing tag for a rich-text attribute. It has no awareness that part of that content came from inner blocks, so binding the list item content overwrites the nested list:
<li>Bound value</li>
The fix: a block's own rich text always renders before its inner blocks, so the replacement only needs to run up to where the first inner block begins. WP_Block::render() already builds the output by walking inner_content, so it knows each inner block's byte offset and can hand those to the replacement, which stops at the first inner block inside the selector.
This is general — there is no list-item-specific code in the render path, so it works for any block that places an inner block inside a bound rich-text element. Blocks without inner blocks are unaffected.
Because it uses real inner-block positions, it also tells apart raw <ul> markup typed into the text (part of the rich text, replaced) from an actual nested block (preserved).
core/list-item is the only core block today whose bound rich-text selector contains an inner block, so this also adds content to its supported binding attributes. The other multi-rich-text blocks (table, pullquote, quote, file, gallery) keep their inner content outside the bound selector and are unaffected.
Steps to reproduce:
- Register a binding source returning a plain string.
- Insert a List, add a list item with a nested list under it.
- Bind the outer list item's
contentto the source. - View the post on the front end.
Expected: the bound text replaces the list item's own text, the nested list stays. Actual (before patch): the nested list is dropped.
Patch and unit tests in Gutenberg's PR https://github.com/WordPress/wordpress-develop/pull/12113, which supersedes the registration-only patch in PR https://github.com/WordPress/wordpress-develop/pull/12084.
Companion Gutenberg compatibility for WordPress 6.9/7.0 in WordPress/gutenberg#78991.
Change History (11)
This ticket was mentioned in PR #12084 on WordPress/wordpress-develop by @sauliusv.
6 weeks ago
#1
- Keywords has-patch added
This ticket was mentioned in PR #12113 on WordPress/wordpress-develop by @cbravobernal.
6 weeks ago
#2
- Keywords has-unit-tests added
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. Closing #12084 in favor of this once reviewed.
## 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:
<li>Updated text</li>
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-itemhandling — 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— registercontentas a bindable attribute forcore/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 duringrender(); pass them throughreplace_html()toreplace_rich_text(), which stops at the first inner block inside the bound selector (boundary-inclusive), and document the$inner_block_offsetsparameter.
## Tests (tests/phpunit/tests/block-bindings/render.php)
test_list_item_content_supports_block_bindings—contentis registered as a bindable attribute forcore/list-item.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 boundcontentattribute 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.- Plus a
core/list-itemcase in the update-from-source data provider.
The render suite passes (20 tests, 54 assertions); the nested-list cases fail on trunk. --group block-bindings and --group blocks pass; PHPCS is clean.
## Scope / notes
- Today
core/list-itemis the only Core block whose bound rich-text selector contains an inner block. Other blocks with multiple rich-text bindings (table, pullquote, file, quote, gallery) keep their inner content outside the bound selector, so they are unaffected. - 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
Claude was 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.
#3
@
6 weeks ago
- Description modified (diff)
- Owner set to
- Status new → assigned
- Summary Block bindings : add support to list-item → Block Bindings: support List Item content binding without dropping nested inner blocks
@cbravobernal commented on PR #12084:
6 weeks ago
#4
Superseeded by https://github.com/WordPress/wordpress-develop/pull/12113
I'll add @saulyz as a contributor when committing.
@jonsurrell commented on PR #12113:
6 weeks ago
#6
### 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:
- The
replace_rich_text()docblock describes$inner_block_offsetsas "Byte offsets in the source HTML", but the offsets are into the assembled$block_contentstring the processor was created from (as the corresponding comment inrender()correctly states). Worth aligning the wording to avoid confusion with the serialized post content.
- In
test_rich_text_binding_preserves_inner_blocks_for_any_block(), theremove_filter()/unregister_block_type()cleanup runs inline after$block->render(). Ifrender()throws,test/rich-text-with-inner-blocksleaks into subsequent tests. Other test classes (e.g.tests/blocks/render.php) do this cleanup intear_down().
🤖 Generated with Claude Code (model: claude-fable-5)
<sub>- If this code review was useful, please react with 👍. Otherwise, react with 👎.</sub>
@cbravobernal commented on PR #12113:
5 weeks ago
#7
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_htmldoes not yet exist, because if an<liis inserted in<li>[https://software.hixie.ch/utilities/js/live-dom-viewer/?%3Cul%3E%3Cli%3E%3Cli%3E [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 anLI, [<li><li>is two adjacentLI, 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.
This ticket was mentioned in PR #12204 on WordPress/wordpress-develop by @cbravobernal.
5 weeks ago
#8
Stacked on #12113 (the generic inner-block preservation fix). That PR should land first; the first commit shown here belongs to it, so review only the second commit — the List Item enablement.
This PR adds core/list-item to the block attributes supported by block bindings, so a List Item's content rich text can be bound. A List Item keeps its rich text and a nested List inside the same <li>, so the binding relies on the inner-block preservation fix from #12113 to avoid dropping the nested list.
Tests cover nested lists, multibyte fallbacks, raw markup, ordered lists, deep nesting with siblings, pattern overrides, and the inner-block-before-text edge case.
This is the core/list-item enablement originally proposed in #12084 by @sauliusv, now split out from #12113 for review hygiene.
@cbravobernal commented on PR #12204:
5 weeks ago
#10
Committed in https://core.trac.wordpress.org/changeset/62518
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)
Work on progress WCEU 2026
Trac ticket: https://core.trac.wordpress.org/ticket/65406#ticket
## Use of AI Tools