Block Bindings: Preserve nested inner blocks when binding rich text#12113
Block Bindings: Preserve nested inner blocks when binding rich text#12113cbravobernal wants to merge 10 commits into
Conversation
Test using WordPress PlaygroundThe 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
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
1a18473 to
d095e16
Compare
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>
4f3c8d1 to
b03fe11
Compare
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>
291db7d to
2f46385
Compare
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>
2f46385 to
9cf3cf6
Compare
|
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 Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
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>
Code review
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 Two minor, non-blocking observations:
wordpress-develop/src/wp-includes/class-wp-block.php Lines 451 to 456 in edac6d1
wordpress-develop/tests/phpunit/tests/block-bindings/render.php Lines 722 to 725 in edac6d1 🤖 Generated with Claude Code (model: - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
There was a problem hiding this comment.
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.
| * 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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>
4186b05 to
48a00b5
Compare
Trac ticket: https://core.trac.wordpress.org/ticket/65406
Builds on @Sauliusv's #12084, which adds
core/list-itemto 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>:If you bind the list item's
contentattribute to a source, Block Bindings replaces everything inside<li>— and the nested list disappears: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:
WP_Block::render()already assembles the output by concatenatinginner_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 toreplace_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:
Why this approach
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.<ul>typed into the text (replaced, it's part of the rich text) from a real nested block (preserved). No tag-name guessing.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/)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.test_replace_rich_text_stops_at_inner_block_offset(inwp-block-get-block-bindings-processor.php) — direct unit test of the new$inner_block_offsetsparameter: the replacement clamps at the recorded offset, preserving the markup that follows it.core/list-itemcase in the update-from-source data provider. Thecore/list-itemregistration itself is covered by that case end to end.The render suite passes; the nested-list cases fail on
trunk.--group block-bindingsand--group blockspass; PHPCS is clean.Scope / notes
core/list-itemis 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.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.