Make WordPress Core

Opened 109 minutes ago

Last modified 42 minutes ago

#66368 assigned defect (bug)

Block attributes that PHP sees as a list are serialized as a JSON array

Reported by: westonruter Owned by: westonruter
Priority: normal Milestone: 7.2
Component: Editor Version:
Severity: normal Keywords: has-patch has-unit-tests
Cc: Focuses:

Description

The serialize_block_attributes() function encodes the attributes array as-is, so an array that PHP sees as a list is serialized as a JSON array rather than an object. The block delimiter grammar only accepts an object, so a block whose attributes come out this way is no longer a block when its content is parsed again.

This happens when a block's attribute names are numeric. The parse_blocks() function decodes the delimiter's JSON into associative arrays, and PHP turns numeric-string keys into integer keys, so {"0":"a"} decodes to array( 0 => 'a' ), which wp_json_encode() then encodes as ["a"]:

<?php
$serialized = serialize_blocks( parse_blocks( '<!-- wp:paragraph {"0":"a"} /-->' ) );
// <!-- wp:paragraph ["a"] /-->

$reparsed = parse_blocks( $serialized );
// $reparsed[0]['blockName'] is null: the paragraph is now freeform content.

The same happens for {"0":"a","1":"b"}, which becomes ["a","b"]. Numeric names that do not count up from zero, such as {"1":"a"}, are not affected, since PHP does not see them as a list.

Called directly, the function also returns:

  • [] for an empty array, rather than {}. Core's callers check empty() first, so they are not affected.
  • An empty string when the attributes cannot be encoded, such as when a value is INF: wp_json_encode() returns false, which strtr() turns into ''. That is not JSON, and the JavaScript serializeAttributes() never returns it. The block grammar happens to tolerate it, since core's callers surround the attributes with spaces, so a paragraph with 'align' => 'wide' and 'ratio' => INF is serialized as <!-- wp:paragraph /-->. It is still parsed as a paragraph, but with none of its attributes, not even align.

Besides a round trip through parse_blocks() and serialize_blocks(), block delimiters are re-serialized through this function by wp_strip_custom_css_from_blocks(), and by the block delimiter normalization in wp_sanitize_html_kses(), which wp_kses() uses when the wp_kses_force_legacy_parser filter, new in 7.2, returns false.

The docblock of serialize_block_attributes() says its output must remain in sync with the JavaScript serializeAttributes() in the block editor. That function takes an attributes object (Record< string, unknown >), which JSON.stringify() always encodes as {…}.

Nested attribute values have a similar problem, which is out of scope here: since the delimiter's JSON is decoded into associative arrays, a nested {} or {"0":"x"} is re-serialized as [] or ["x"].

Related: #65817

🤖 Ticket drafted with support from Claude Opus 5.5.

Change History (4)

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


103 minutes ago
#1

  • Keywords has-patch has-unit-tests added

The serialize_block_attributes() function encodes the attributes array as-is, so an array that PHP sees as a list comes out as a JSON array, which the block delimiter grammar does not accept. A block whose attribute names are numeric, such as , is re-serialized as , and is no longer a block when parsed again. The function also returns [] for no attributes, and an empty string when the attributes cannot be encoded, losing all of them. The ticket has the details.

This makes its output match the block editor's serializeAttributes(), which its docblock says it must stay in sync with, and tightens the types and documentation of wp_json_encode() along the way.

serialize_block_attributes()

  • Lists are cast to an object before encoding, so array() is serialized as {} and array( 0 => 'a' ) as {"0":"a"}. Only a list is cast, not every array, since json_encode() skips object properties whose names start with a null byte, which an attribute name decoded from JSON may do. The JSON_FORCE_OBJECT flag is not an option either, since it would also turn nested list values, such as an array of class names, into objects.
  • INF and NAN are serialized as null, as JSON.stringify() does, rather than losing every attribute. When encoding fails with JSON_ERROR_INF_OR_NAN, each one is replaced, including in nested arrays, and the attributes are encoded again: a paragraph with 'align' => 'wide' and 'ratio' => INF becomes . The values are replaced in copies of the arrays, so a variable that an attribute refers to by reference is left unchanged. Encoding is not retried for any other failure.
  • {} is returned when the attributes still cannot be encoded, such as when they are nested more than 512 levels deep or refer to themselves, rather than an empty string, which is not JSON.
  • A non-array argument keeps working. The function encoded an object before, and the check for a list is limited to arrays so it does not throw a TypeError for one.
  • Types: a native string return type, and the documented array<string, mixed> for the attributes object. For PHPStan, the attributes are typed as array<array-key, mixed>, since attributes decoded from a delimiter can have integer keys, and the return value as non-empty-string.

The @since 7.2.0 entry assumes this lands in 7.2 as a bug fix after Beta 1; it becomes 7.3.0 otherwise.

wp_json_encode()

  • $depth is typed as int<1, max>, matching its description, which already says it must be greater than 0.
  • The description of $flags now says that with JSON_THROW_ON_ERROR, but not JSON_PARTIAL_OUTPUT_ON_ERROR, a JsonException is thrown before the confidence checks can recover from an error such as invalid UTF-8, and @return says false is only returned without it. This is not a @throws tag, since PHPStan would then take JsonException to be all that can be thrown, whereas a JsonSerializable::jsonSerialize() in $value can throw anything.
  • The return type stays non-empty-string|false. Tests cover the values that encode to the falsy '0', which is why it cannot be non-falsy-string. A conditional return type excluding false for JSON_THROW_ON_ERROR was tried and removed, as the commits show: it needs the full list of encoding flags twice, and a docblock on a function cannot define a type alias to avoid that.

Not addressed

  • When the attributes cannot be encoded for any other reason, they are all lost, as they are now, though the output is now {}. JSON.stringify() has no depth limit and throws for a circular reference, but neither case can come from attributes parsed from block markup.
  • INF and NAN inside an object passed in the attributes are not replaced, since the object belongs to the caller, so such attributes still fall back to {}.
  • Nested attribute values have a similar problem: since the delimiter's JSON is decoded into associative arrays, a nested {} or {"0":"x"} is re-serialized as [] or ["x"]. Fixing that needs a change to how attributes are decoded, not to how they are serialized.

Testing

npm run test:php -- tests/phpunit/tests/blocks/serialize.php
npm run test:php -- --filter test_wp_json_encode tests/phpunit/tests/functions.php

Against trunk's serialize_block_attributes(), 13 of the new test cases fail: the round trips of a numeric attribute name, which come out as ["a"], a list or no attributes serialized as a JSON array, and every case that fails to encode, which returns an empty string. The test for an object argument passes on trunk; it covers a regression that an earlier commit here introduced and fixed.

See also Core-65817.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5
Used for: Implementing the changes and tests under direction, comparing the PHP output with JSON.stringify() and PHPStan's inferred types, reproducing the issues on trunk, and addressing code review findings.

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

🤖 Generated with ​Claude Code

​@westonruter commented on ​PR #14758:


100 minutes ago
#2

🤖 _Comment by Claude Opus 5.5_

PHPStan diff: origin/trunk → improve/json-encode-serialize-block-attributes-types

Analyzed with the local phpstan.neon (level 10 with bleedingEdge) and PHPStan 2.3.1, on a cold result cache for each pass. The base is origin/trunk at e29e51dac6, which is this branch's merge base.

Summary

Metric Value
Errors in origin/trunk 27,467
Errors in this branch 27,463
Net change -4
Errors fixed 6
Errors introduced 2

<sub>Per-occurrence multiset diff, keyed by file, identifier, and message: 6 fixed − 2 introduced = 4 net. ✓</sub>

Errors fixed (6)

File Identifier Message
src/wp-includes/blocks.php missingType.iterableValue Function serialize_block_attributes() has parameter $block_attributes with no value type specified in iterable type array.
src/wp-includes/blocks.php argument.type Parameter #1 $str of function strtr expects string, string\||false given.
src/wp-includes/functions.php argument.type Parameter #3 $depth of function json_encode expects int<1, max>, int given. (×2)
src/wp-includes/block-supports/custom-css.php argument.type Parameter #1 $block_attributes of function serialize_block_attributes expects array, mixed given.
src/wp-includes/kses.php argument.type Parameter #1 $block_attributes of function serialize_block_attributes expects array, array\||string given.

Errors introduced (2)

File Identifier Message
src/wp-includes/block-supports/custom-css.php argument.type Parameter #1 $block_attributes of function serialize_block_attributes expects array<mixed>, mixed given.
src/wp-includes/kses.php argument.type Parameter #1 $block_attributes of function serialize_block_attributes expects array<mixed>, array\||string given.

Triage

The two introduced errors are not new. They are the last two fixed errors, at the same lines (custom-css.php:447 and kses.php:1709), with the expected type reworded from array to array<mixed> now that the parameter has a @phpstan-param array<array-key, mixed>. The callers pass mixed and array|string both before and after, so they remain to be narrowed separately.

That leaves four errors genuinely fixed, with none introduced:

  • The parameter of serialize_block_attributes() now has a value type.
  • strtr() is no longer passed false, since a failed encoding now returns {} instead.
  • json_encode() receives a $depth of int<1, max> from wp_json_encode(), which documents it as greater than 0.

#3 @dmsnell
70 minutes ago

@westonruter I’m not sure this is a full duplicate of #63325, but it’s definitely related with a strong overlap, and I think ​PR#14758 addresses the case where all attributes are empty, but not where individual attribute values are the empty array, which is something that ​PR#8789 was working toward and which would cover this case too, I think.

this ticket though seems more like a bug report that the PHP serialization diverges from the JS serialization, and covers cases that aren’t caused by an empty associative array in PHP being indistinguishable from an empty list.

I had ​suggested looking into meta properties to carry along the provenance, which has its own challenges because we want to make sure that PHP code doesn’t use those meta values.

also I had explored ​a wrapping class for block attributes which could wrap that origin for re-serialization into a map.

maybe it’s also time to explore those or work together on these issues.

#4 @westonruter
42 minutes ago

@dmsnell Oh yeah, that ticket is definitely related, but yeah, this one is about the JSON data integrity and not about checking the expected types of the individual attributes. So maybe the INF and NAN parts should be removed from my PR to defer to the other?

Note: See TracTickets for help on using tickets.