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 checkempty()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()returnsfalse, whichstrtr()turns into''. That is not JSON, and the JavaScriptserializeAttributes()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' => INFis serialized as<!-- wp:paragraph /-->. It is still parsed as a paragraph, but with none of its attributes, not evenalign.
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
@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 passedfalse, since a failed encoding now returns{}instead.json_encode()receives a$depthofint<1, max>fromwp_json_encode(), which documents it as greater than 0.
#3
@
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
@
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?
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)
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 ofwp_json_encode()along the way.serialize_block_attributes()array()is serialized as{}andarray( 0 => 'a' )as{"0":"a"}. Only a list is cast, not every array, sincejson_encode()skips object properties whose names start with a null byte, which an attribute name decoded from JSON may do. TheJSON_FORCE_OBJECTflag is not an option either, since it would also turn nested list values, such as an array of class names, into objects.INFandNANare serialized asnull, asJSON.stringify()does, rather than losing every attribute. When encoding fails withJSON_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' => INFbecomes. 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.TypeErrorfor one.stringreturn type, and the documentedarray<string, mixed>for the attributes object. For PHPStan, the attributes are typed asarray<array-key, mixed>, since attributes decoded from a delimiter can have integer keys, and the return value asnon-empty-string.The
@since 7.2.0entry assumes this lands in 7.2 as a bug fix after Beta 1; it becomes7.3.0otherwise.wp_json_encode()$depthis typed asint<1, max>, matching its description, which already says it must be greater than 0.$flagsnow says that withJSON_THROW_ON_ERROR, but notJSON_PARTIAL_OUTPUT_ON_ERROR, aJsonExceptionis thrown before the confidence checks can recover from an error such as invalid UTF-8, and@returnsays false is only returned without it. This is not a@throwstag, since PHPStan would then takeJsonExceptionto be all that can be thrown, whereas aJsonSerializable::jsonSerialize()in$valuecan throw anything.non-empty-string|false. Tests cover the values that encode to the falsy'0', which is why it cannot benon-falsy-string. A conditional return type excluding false forJSON_THROW_ON_ERRORwas 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
{}.JSON.stringify()has no depth limit and throws for a circular reference, but neither case can come from attributes parsed from block markup.INFandNANinside an object passed in the attributes are not replaced, since the object belongs to the caller, so such attributes still fall back to{}.{}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
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