Editor: Preserve empty object block attributes through parse and serialize - #13197
Editor: Preserve empty object block attributes through parse and serialize#13197margolisj wants to merge 2 commits into
Conversation
A block attribute written as `{}` is decoded with `json_decode( $json, true )`,
which produces an empty array. Serializing that array writes `[]` back out, so
any workflow that parses stored markup and saves it again rewrites the
attribute and invalidates the block in the editor.
Add `WP_Block_Parser::parse_with_options()`, which accepts a
`preserve_empty_object_attributes` option. With that option enabled, attribute
JSON is decoded to objects and every value except a nested empty object is
converted back to the array shape the default parse path produces. The empty
object is kept as an empty `stdClass`, which `wp_json_encode()` writes as `{}`.
No marker key or restore step is involved.
The option is off by default, so `parse()` and `parse_blocks()` behave exactly
as before. Attribute strings containing no `{}` token cannot hold an empty
object, and skip the conversion walk entirely.
`parse_with_options()` is a separate method rather than a second parameter on
`parse()` so that a parser subclass registered through `block_parser_class` can
keep overriding `parse()` with its existing signature.
Props margolisj.
See #63325.
Block Hooks and `filter_block_content()` both parse stored block markup and
serialize it back, so both rewrite an attribute written as `{}` into `[]`.
Switch them to the preserving parse.
Rendering is deliberately left alone. `do_blocks()` does not write parsed
content back, so it keeps the default parse and its existing cost.
Preservation places an empty `stdClass` in the parsed attributes, which
existing extension points have never received. `insert_hooked_blocks()` and
`set_ignored_hooked_blocks_metadata()` therefore hand filters an anchor block
converted back to all-array attributes, and read `metadata` through an array
cast: in PHP an array offset on an object is a fatal error, including inside
`isset()` and `??`, and including assignment.
`insert_hooked_blocks()` now returns before that conversion when no hooked
block types remain, matching the early return already in
`set_ignored_hooked_blocks_metadata()`. The check runs after the
`hooked_block_types` filter, which can add a type to an anchor that has none
registered.
Populated objects are still decoded as arrays. Distinguishing them would change
what KSES traverses and what array-shaped attributes plugins receive, which is
out of scope here.
Props margolisj.
See #63325.
|
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. |
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. |
A block attribute written as
{}is decoded withjson_decode( $json, true ), which produces an empty PHP array. Serializing that array writes[]back out. Any workflow that parses stored block markup and saves it again therefore rewrites the attribute, and the editor then reports the block as invalid.This replaces #8735, which approached the same ticket by changing the serializer. The block delimiter format is co-owned by JavaScript —
@wordpress/blockswrites it withJSON.stringify()and@wordpress/block-serialization-default-parserreads it withJSON.parse()— so the fix belongs on the PHP decode side instead.Approach
WP_Block_Parser::parse_with_options()accepts apreserve_empty_object_attributesoption. With it enabled, attribute JSON is decoded to objects and every value except a nested empty object is converted back to the array shape the default parse path produces. The empty object stays an emptystdClass, whichwp_json_encode()writes as{}.There is no marker key, no sentinel value, and no restore step.
wp_json_encode()already distinguishes an empty object from an empty array, so the parsed representation carries the difference on its own.serialize_block_attributes()is untouched.Two workflows opt in, because they are the ones that parse stored markup and write it back:
apply_block_hooks_to_content()filter_block_content()— the save-time path the bug was reported against, which runs for every user without theunfiltered_htmlcapabilityparse(),parse_blocks(), and rendering throughdo_blocks()are unchanged.do_blocks()does not write parsed content back, so it keeps the default parse and its existing cost.parse_with_options()is a separate method rather than a second parameter onparse()because a parser registered throughblock_parser_classmay subclassWP_Block_Parserand overrideparse(); PHP rejects an override declaring fewer parameters than its parent. Delegating toparse()also keeps any such override in effect. A replacement parser that predates the new method falls back toparse().Compatibility
Preservation puts an empty
stdClasswhere extension points have always received an array. Two things follow:insert_hooked_blocks()andset_ignored_hooked_blocks_metadata()hand thehooked_blockfilters an anchor block converted back to all-array attributes.metadatathrough an array cast. In PHP an array offset on an object is a fatal error, including insideisset()and??, and including assignment.Out of scope
Populated objects are still decoded as arrays, so
{"0":"a","1":"b"}still serializes as["a","b"]. Distinguishing those would change what KSES traverses and what shape plugins receive from attributes they already read as arrays. That is a wider compatibility question than this ticket needs.Performance
Preservation costs a second decode plus a conversion walk, so both consumers pay for it. Two things limit that: attribute strings containing no
{}token cannot hold an empty object and skip the walk entirely, andinsert_hooked_blocks()returns before converting the anchor block when no hooked block types remain after thehooked_block_typesfilter.Measured on PHP 8.3.31 against this branch's merge base. Timing method: 8 runs per template, take each template's minimum, report the median across templates.
apply_block_hooks_to_content()over 478 templates in which 81.3% of attribute strings contain an empty object, with a hooked block registered:Attribute decoding across the 394 attribute strings in the bundled themes, none of which contain an empty object:
The early return in
insert_hooked_blocks()is what keeps the first table from roughly doubling; the lexical skip is what keeps the second from it. Content rendered throughdo_blocks()is unaffected.Testing
New tests cover the parser option, the boundaries of the lexical skip, and both consumers:
{ },{\t},{\n},{\r}) still counts as an empty object{}inside a string value stays a string and round-trips byte-identically<!-- wp:test {} /-->still serializes without attributesparse(), and a subclass overridingparse()with the historical signature, both keep workinghooked_block_typesfilter can still inject a hooked block into an anchor with none registered, which is what pins the early return after the filter rather than before itCommands run:
1594 tests, 4444 assertions, all passing. Each of the two commits passes this group on its own.
Both clean.
Separately, against a corpus of 478 real block templates containing 41,569 empty object attributes: trunk destroys 41,406 of them (99.6%) and corrupts 476 of the 478 templates. With this branch all 41,569 are preserved, all 478 templates round-trip byte-identically, and no internal representation reaches the serialized output.
Trac ticket: https://core.trac.wordpress.org/ticket/63325
Related: WordPress/gutenberg#69959
Replaces: #8735
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Investigating the regression, drafting the implementation and the tests, and building and running the benchmarks. The approach and scope were mine, and I reviewed the changes before submitting.
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.