Make WordPress Core

Opened 4 months ago

Closed 4 months 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 cbravobernal)

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:

  1. Register a binding source returning a plain string.
  2. Insert a List, add a list item with a nested list under it.
  3. Bind the outer list item's content to the source.
  4. 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.


4 months ago
#1

  • Keywords has-patch added

Work on progress WCEU 2026

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

## Use of AI Tools

This ticket was mentioned in ​PR #12113 on ​WordPress/wordpress-develop by ​@cbravobernal.


4 months 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-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/render.php)

  • test_list_item_content_supports_block_bindings — content is registered as a bindable attribute for core/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 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.
  • Plus a core/list-item case 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-item is 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 @cbravobernal
4 months ago

  • Description modified (diff)
  • Owner set to cbravobernal
  • 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:


4 months ago
#4

Superseeded by ​https://github.com/WordPress/wordpress-develop/pull/12113

I'll add @saulyz as a contributor when committing.

#5 @cbravobernal
4 months ago

  • Milestone Awaiting Review → 7.1

​@jonsurrell commented on ​PR #12113:


4 months 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:

  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.

​https://github.com/WordPress/wordpress-develop/blob/edac6d1be1b8e2303201b105968971de7fcd719a/src/wp-includes/class-wp-block.php#L451-L456

  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().

​https://github.com/WordPress/wordpress-develop/blob/edac6d1be1b8e2303201b105968971de7fcd719a/tests/phpunit/tests/block-bindings/render.php#L722-L725

🤖 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:


4 months 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_html does not yet exist, because if an <li is 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 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.

This ticket was mentioned in ​PR #12204 on ​WordPress/wordpress-develop by ​@cbravobernal.


4 months 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.

#9 @cbravobernal
4 months ago

In 62518:

Block Bindings: Add List Item Block Support.

Add core/list-item to the block attributes supported by block bindings so its content rich text can be bound, and cover the basic (non-nested) case in the render tests.

This is a clean enablement with no render-side changes. A List Item that contains a nested List keeps both inside the same <li>; preserving that nested list when content is bound is handled separately by the WP_Block::replace_html() inner-block fix.

Props sauliusv, cbravobernal.
See #65406.

#11 @cbravobernal
4 months ago

  • Resolution → fixed
  • Status assigned → closed

In 62522:

Block Bindings: Preserve nested inner blocks when binding rich text.

WP_Block::replace_html() replaced the entire element matched by a rich-text attribute's selector, dropping any markup produced by inner blocks rendered inside that element (e.g. a List nested inside a List Item).

Props cbravobernal, jonsurrell.
Fixes #65406.

Note: See TracTickets for help on using tickets.