Skip to content

Editor: Preserve empty object block attributes through parse and serialize - #13197

Open
margolisj wants to merge 2 commits into
WordPress:trunkfrom
margolisj:fix/63325-empty-object-block-attributes
Open

Editor: Preserve empty object block attributes through parse and serialize#13197
margolisj wants to merge 2 commits into
WordPress:trunkfrom
margolisj:fix/63325-empty-object-block-attributes

Conversation

@margolisj

Copy link
Copy Markdown

A block attribute written as {} is decoded with json_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/blocks writes it with JSON.stringify() and @wordpress/block-serialization-default-parser reads it with JSON.parse() — so the fix belongs on the PHP decode side instead.

Approach

WP_Block_Parser::parse_with_options() accepts a preserve_empty_object_attributes option. 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 empty stdClass, which wp_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:

  • the Block Hooks algorithm, through apply_block_hooks_to_content()
  • block content filtering through KSES, in filter_block_content() — the save-time path the bug was reported against, which runs for every user without the unfiltered_html capability

parse(), parse_blocks(), and rendering through do_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 on parse() because a parser registered through block_parser_class may subclass WP_Block_Parser and override parse(); PHP rejects an override declaring fewer parameters than its parent. Delegating to parse() also keeps any such override in effect. A replacement parser that predates the new method falls back to parse().

Compatibility

Preservation puts an empty stdClass where extension points have always received an array. Two things follow:

  • insert_hooked_blocks() and set_ignored_hooked_blocks_metadata() hand the hooked_block filters an anchor block converted back to all-array attributes.
  • Both 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.

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, and insert_hooked_blocks() returns before converting the anchor block when no hooked block types remain after the hooked_block_types filter.

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:

median
trunk 214.5 µs
this branch 282.5 µs (+32%)

Attribute decoding across the 394 attribute strings in the bundled themes, none of which contain an empty object:

total
trunk 0.187 ms
this branch 0.216 ms (+15%)
without the lexical skip 0.352 ms (+88%)

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 through do_blocks() is unaffected.

Testing

New tests cover the parser option, the boundaries of the lexical skip, and both consumers:

  • whitespace between the braces ({ }, {\t}, {\n}, {\r}) still counts as an empty object
  • a {} inside a string value stays a string and round-trips byte-identically
  • a real empty object and a lookalike string in the same attributes are told apart
  • the top-level attribute container still becomes an array, so <!-- wp:test {} /--> still serializes without attributes
  • malformed attribute JSON behaves identically on both paths
  • a replacement parser implementing only parse(), and a subclass overriding parse() with the historical signature, both keep working
  • KSES still sanitizes strings in a populated object sitting beside a preserved empty object
  • a hooked_block_types filter 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 it

Commands run:

npm run test:php -- --group blocks,kses,block-hooks

1594 tests, 4444 assertions, all passing. Each of the two commits passes this group on its own.

node ./tools/local-env/scripts/docker.js run --rm php ./vendor/bin/phpcs src/wp-includes/blocks.php src/wp-includes/class-wp-block-parser.php
node ./tools/local-env/scripts/docker.js run --rm php ./vendor/bin/phpstan analyse --memory-limit=2G src/wp-includes/blocks.php src/wp-includes/class-wp-block-parser.php

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.

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.
@github-actions

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 margolisj.

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

@github-actions

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant