Repository navigation
Blocks: Always serialize block attributes as a JSON object, matching serializeAttributes() - #14758
westonruter wants to merge 23 commits into
Conversation
…ger. The `$depth` parameter of `wp_json_encode()` is documented as having to be greater than 0, so type it as `int<1, max>`. Also add tests for the values that can encode to the falsy string `'0'`, which is why the function returns a `non-empty-string` rather than a `non-falsy-string`: an int or float zero, a numeric string with `JSON_NUMERIC_CHECK`, `INF` or `NAN` with `JSON_PARTIAL_OUTPUT_ON_ERROR`, and a `JsonSerializable` object that serializes to zero. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`serialize_block_attributes()` encoded the attributes array as is, so an
array that PHP sees as a list came out as a JSON array, which the block
delimiter grammar does not accept:
- An empty array was serialized as `[]`.
- Attributes decoded from `{"0":"a"}`, whose numeric name becomes an
integer key, were serialized as `["a"]`, so a block with them did not
survive a parse and serialize round trip, and was no longer a block after
kses normalized its delimiter.
Cast a list to an object before encoding, so it is serialized as `{}` or
`{"0":"a"}`. Other arrays are left alone, since `json_encode()` skips
object properties whose names start with a null byte, which an attribute
name decoded from JSON may do.
If the attributes cannot be encoded, an empty object is now returned rather
than an empty string, which `strtr()` made of `false`.
Also add a `string` return type, narrow the documented type of the
attributes to `array<string, mixed>`, matching the attributes object of the
JavaScript `serializeAttributes()`, and type the return value as a
`non-empty-string`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`wp_json_encode()` is documented to return false when a value cannot be encoded, but when `$flags` includes `JSON_THROW_ON_ERROR`, the first `json_encode()` call throws a `JsonException` instead. The exception is not caught, so it is thrown before the confidence checks can recover from an error they would otherwise handle, such as invalid UTF-8. Document the exception with a `@throws` tag, and qualify the return value, with tests for a value that cannot be encoded, one that exceeds the depth, and invalid UTF-8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ERROR. When `$flags` includes `JSON_THROW_ON_ERROR`, `wp_json_encode()` throws a `JsonException` instead of returning false. Make the return type conditional on `$flags`: when it is a combination of the other encoding flags, the function returns `non-empty-string|false`, and otherwise only `non-empty-string`. A non-constant `$flags` matches neither for certain, so it keeps the full `non-empty-string|false`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `@throws` tag makes PHPStan treat the listed exceptions as all that a function can throw. But `json_encode()` also lets through any exception thrown by a `JsonSerializable::jsonSerialize()` in `$value`, whatever the flags, so `@throws JsonException` would make a catch of any other exception around `wp_json_encode()` look dead. Move the note about `JSON_THROW_ON_ERROR` into the description of `$flags` instead, so the function goes back to being treated as possibly throwing anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`array_is_list()` requires an array, so passing an object to `serialize_block_attributes()`, which it encoded fine before, now threw a `TypeError`. Only check for a list when the attributes are an array. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…flag. The conditional return type of `wp_json_encode()` excluded false whenever `$flags` was not a combination of the known encoding flags without `JSON_THROW_ON_ERROR`. That also covered flags with an unknown bit set, such as one added in a later version of PHP, which can still fail and return false. Exclude false only when `$flags` is a combination of the known encoding flags that includes `JSON_THROW_ON_ERROR`, and keep it for any other value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`serialize_block_attributes()` now serializes a list or an empty array as a JSON object rather than an array, and returns an empty object rather than an empty string when the attributes cannot be encoded. Add a `@since` entry so the change is visible in the developer documentation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excluding false from the return type when `$flags` includes `JSON_THROW_ON_ERROR` needs the full list of encoding flags twice: once to match flags without it, and once to match flags with it. A docblock on a function cannot define a type alias, and one defined in the PHPStan configuration would be unknown to stubs, IDEs, and anyone else analyzing code against core. No caller in core passes `JSON_THROW_ON_ERROR`, so the narrowing gave core nothing, while the list of flags would have to be kept in sync with PHP. Go back to `non-empty-string|false`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When `$flags` includes both `JSON_THROW_ON_ERROR` and `JSON_PARTIAL_OUTPUT_ON_ERROR`, `json_encode()` returns the partial output rather than throwing, for example `'0'` for `INF`. Say so in the description of `$flags`, and add the case to the test of values that can encode to `'0'`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`serialize_block_attributes()` encoded attributes passed as an object, but the check for a list made it throw a `TypeError` for one until the check was limited to arrays. Add a regression test. The object goes against the documented array type on purpose, so the PHPStan error for it is ignored with the reason given. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…PHPStan.
Attribute names are strings, but when `parse_blocks()` decodes a
delimiter's JSON, PHP turns numeric-string names into integer keys, so
`{"0":"a"}` is passed in as `array( 0 => 'a' )`. Serializing exactly those
is why a list is cast to an object.
With the attributes typed as `array<string, mixed>`, PHPStan considers a
list impossible, and takes that as certain once the encoding result is
checked, which would report the cast in a retry as dead code. Keep the
documented `array<string, mixed>`, which describes the attributes object,
and give PHPStan the actual `array<array-key, mixed>`, here and in the
test that passes integer keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JSON cannot represent `INF` or `NAN`, so a block with one in its
attributes failed to encode, and `serialize_block_attributes()` returned
`{}`, losing all of the block's attributes, not just that one. The
`JSON.stringify()` in the block editor's `serializeAttributes()` encodes
them as `null` instead, keeping the rest.
Do the same: when encoding fails, replace any `INF` or `NAN` in the
attributes, including in nested arrays, with null, and encode them again.
Objects in the attributes are left alone, since they belong to the caller.
Attributes that still cannot be encoded are serialized as `{}`, as
before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…@SInCE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The retry that replaces `INF` and `NAN` with null ran after any failure to encode the attributes. For attributes that refer to themselves, `array_walk_recursive()` then threw an `Error` for the recursion, where the function used to fail quietly, and other failures, such as exceeding the depth limit, paid for a walk and a second encoding that could not succeed. Only retry when `json_last_error()` reports `JSON_ERROR_INF_OR_NAN`, and add a test for self-referencing attributes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`array_walk_recursive()` replaced `INF` and `NAN` in place, and though the attributes array is a copy, an element that is a reference still points at the caller's variable, so serializing the attributes set that variable to null. Build new arrays with the values replaced instead, walking no deeper than `json_encode()` does as a safeguard, and add a test that a referenced variable is left unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recursive closure captures itself by reference, so it is left in a reference cycle that only the cycle collector frees. Clear the variable once the replacement is done, which breaks the cycle and frees the closure right away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ject. Add `@ticket` tags to the tests of `serialize_block_attributes()`, and to the round trip tests whose data now includes a numeric attribute name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Comment by Claude Opus 5.5 PHPStan diff:
|
| Metric | Value |
|---|---|
Errors in origin/trunk |
27,467 |
| Errors in this branch | 27,463 |
| Net change | -4 |
| Errors fixed | 6 |
| Errors introduced | 2 |
Per-occurrence multiset diff, keyed by file, identifier, and message: 6 fixed − 2 introduced = 4 net. ✓
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.
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. |
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. 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. Props Bot commandsYou can trigger Props Bot actions by commenting on this PR:
|
| * cast to an object, since json_encode() skips object properties whose names start with a | ||
| * null byte, which an attribute name decoded from JSON may do. | ||
| */ | ||
| $is_list = is_array( $block_attributes ) && array_is_list( $block_attributes ); |
There was a problem hiding this comment.
wouldn’t an array/list of attributes be illegal here anyway? is there a legitimate way to store numeric indices as attribute keys?
There was a problem hiding this comment.
PHP automatically converts string digit keys into integer keys:
$array = array( "0" => "foo2" );
var_dump( $array );
var_dump( array_is_list( $array ) );array(1) {
[0]=>
string(4) "foo2"
}
bool(true)
There was a problem hiding this comment.
🤖 Comment by Claude Opus 5.5
Numeric attribute names can come straight out of parsed content. For example, parse_blocks( '<!-- wp:void {"0":"a"} /-->' ) gives attrs of array( 0 => 'a' ), which is a list. Without the cast, it would be serialized back as ["a"], which the block grammar doesn't accept as attributes. That case has been added to data_serialize_identity_from_parsed() so that the round trip is tested.
So whether or not such names are legitimate, they can appear in content, and the serializer shouldn't turn them into invalid block markup.
There was a problem hiding this comment.
right. I guess my implied question was whether we should reject these entirely if we get a numeric list rather than an associative array
There was a problem hiding this comment.
So you think we should just return {} in the case of a list?
There was a problem hiding this comment.
well that’s a question I think this raises. I don’t know if there’s a legitimate or supported reason to store attribute values as numeric indices.
would any PHP code already break if provided <!-- wp:a {"0":"a"} -->, for example. this probably is the same question for passing through a sentinel meta value
There was a problem hiding this comment.
I think the question is what JS does. If the JS function is canonical then if JS allows for integer keys in an object to be serialized, then PHP should too.
|
@dmsnell Aside: I stumbled across this while reviewing how the block attributes are constructed in the Ipsum theme's block patterns. Namely, it is embedding |
| * JSON cannot represent INF or NAN, so encoding fails if any attribute value is one. The | ||
| * JSON.stringify() in serializeAttributes() encodes them as null instead, so do the same | ||
| * and try again, rather than losing all the attributes. Values in nested arrays are | ||
| * replaced too, but not those in objects, which belong to the caller. |
There was a problem hiding this comment.
it also fails if provided invalid UTF-8 sequences. we can optionally pass in JSON_INVALID_UTF8_SUBSTITUTE to replace those sequences with the replacement character.
There was a problem hiding this comment.
🤖 Comment by Claude Opus 5.5
Encoding didn't actually fail outright here: when json_encode() fails on invalid UTF-8, wp_json_encode() retries with each string passed through _wp_json_convert_string(), which turns each invalid byte into ?. So array( 'content' => "a\xFFb" ) was serialized as {"content":"a?b"}, with the replacement indistinguishable from a real question mark.
JSON_INVALID_UTF8_SUBSTITUTE has been added in ccda909, so that now serializes as {"content":"a�b"} (attribute names included), with tests for values, names, nested arrays, and in combination with INF.
Two differences from before are worth noting:
- PHP substitutes one U+FFFD per invalid span, which isn't quite the maximal subpart practice of
wp_scrub_utf8(). A surrogate half such as"\xED\xA0\x80"becomes one U+FFFD fromjson_encode(), but three fromwp_scrub_utf8(). - If a site has added a legacy charset like
ISO-8859-1tomb_detect_order(),_wp_json_convert_string()would have converted such a string to UTF-8, whereas now it gets U+FFFD. The default order is onlyASCII, UTF-8, so this seems unlikely to matter.
|
|
||
| if ( ! is_array( $value ) || $depth <= 0 ) { | ||
| return $value; | ||
| } |
There was a problem hiding this comment.
it took me some time to think through this depth check, because my instinct is to say that we would have received JSON_ERROR_DEPTH if it hit the limit, but I see that JSON_ERROR_INF_OR_NAN could be hiding a max-depth error that would have turned up had we gotten further along.
it could be worth noting this in a comment
There was a problem hiding this comment.
🤖 Comment by Claude Opus 5.5
It turns out JSON_ERROR_INF_OR_NAN can't hide a depth error: json_encode() doesn't stop at an INF or NAN value, but records the error and carries on (with JSON_PARTIAL_OUTPUT_ON_ERROR, the value is emitted as 0). So an error further along replaces it. On PHP 8.2 and 8.5:
| Value | json_last_error_msg() |
|---|---|
INF and then 600 levels of nesting |
Maximum stack depth exceeded |
600 levels of nesting and then INF |
Maximum stack depth exceeded |
INF and then a self-reference |
Recursion detected |
INF and then invalid UTF-8 |
Malformed UTF-8 characters, possibly incorrectly encoded |
So when the error is JSON_ERROR_INF_OR_NAN, the arrays being walked are no deeper than json_encode() allows, and the depth limit is only a safeguard against unbounded recursion. The comment has been expanded to say this in 921e1d2, along with a test of INF next to attributes nested too deeply, which serializes as {}.
Since the limit can't be reached, the $depth parameter could be removed. It was kept as a cheap guard in case the engine ever behaves differently.
There was a problem hiding this comment.
I have something to say, but I’m less interested in chatting with Claude than with you. something about this response seems factually wrong and simultaneously missing the point.
There was a problem hiding this comment.
Sorry. I wanted to apply your feedback quickly before following more deeply in person to evaluate further. Bot replies suspended.
There was a problem hiding this comment.
Looking at this, it seems like if the array had both an INF and was greater than 512 depth, then I guess it would have two errors: JSON_ERROR_INF_OR_NAN and JSON_ERROR_DEPTH. Or maybe not.
Actually, isn't the $depth just a guard so that recurse deeper than the max level allowed anyway?
So if the array is deeper than 512 levels, then it would indeed have a JSON_ERROR_DEPTH error being reported and by json_last_error(), and the if statement wouldn't be entered in the first place.
In this case, I suppose we wouldn't technically need the $depth because we know it isn't deeper than 512 anyway since it didn't raise the JSON_ERROR_DEPTH error? Right:
<?php
$array = array(
array(
array(
INF
)
)
);
$flags = 0;
$depth = 2;
var_dump( json_encode( $array, $flags, $depth ) );
var_dump( json_last_error_msg() );bool(false)
string(28) "Maximum stack depth exceeded"
https://3v4l.org/fL0eO#v8.5.11
So indeed, the depth seems unnecessary to track.
There was a problem hiding this comment.
So we could simplify down to:
diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php
index a2ed97d9bc..83164aab1f 100644
--- a/src/wp-includes/blocks.php
+++ b/src/wp-includes/blocks.php
@@ -1743,27 +1743,29 @@ function serialize_block_attributes( $block_attributes ): string {
/**
* Replaces INF and NAN with null in a value and in the arrays it contains.
*
+ * Note there is no risk to infinite recursion because the wp_json_encode() call above didn't fail with
+ * a `JSON_ERROR_DEPTH` error.
+ *
* @param mixed $value Attribute value.
- * @param int $depth How many more levels of arrays to walk.
* @return mixed The value, with INF and NAN replaced.
*/
- $replace_non_finite_numbers = static function ( $value, int $depth ) use ( &$replace_non_finite_numbers ) {
+ $replace_non_finite_numbers = static function ( $value ) use ( &$replace_non_finite_numbers ) {
if ( is_float( $value ) && ! is_finite( $value ) ) {
return null;
}
- if ( ! is_array( $value ) || $depth <= 0 ) {
+ if ( ! is_array( $value ) ) {
return $value;
}
$replaced = array();
foreach ( $value as $key => $item ) {
- $replaced[ $key ] = $replace_non_finite_numbers( $item, $depth - 1 );
+ $replaced[ $key ] = $replace_non_finite_numbers( $item );
}
return $replaced;
};
- $block_attributes = $replace_non_finite_numbers( $block_attributes, 512 );
+ $block_attributes = $replace_non_finite_numbers( $block_attributes );
// Free the closure now, since it refers to itself and would otherwise wait for the cycle collector.
$replace_non_finite_numbers = null;🧠 Written without AI.
There was a problem hiding this comment.
In this case, I suppose we wouldn't technically need the $depth because we know it isn't deeper than 512 anyway since it didn't raise the JSON_ERROR_DEPTH error? Right
this is what originally drew my attention (and my gut ended up being right), and then I (incorrectly) assumed the INF error could mask the depth. the more compelling test code, I think, is this
<?php
$array = array(
INF,
array(
array(
INF
)
)
);
$flags = 0;
$depth = 2;
var_dump( json_encode( $array, $flags, $depth ) );
var_dump( json_last_error_msg() );bool(false)
string(28) "Maximum stack depth exceeded"
This shows that the depth error supersedes the INF error, and could thus hide that instead. I’m not sure there’s a public contract for these error codes and their priorities. according to Claude, INF/NaN is the only error that doesn’t stop parsing, and the depth check occurs later, overwriting this error flag. I confirmed this in testing, though I only skimmed the source and didn’t fully confirm there.
this all just confirms what you found: the error will only be INF/NaN if the depth was not exceeded.
| $block_attributes = $replace_non_finite_numbers( $block_attributes, 512 ); | ||
|
|
||
| // Free the closure now, since it refers to itself and would otherwise wait for the cycle collector. | ||
| $replace_non_finite_numbers = null; |
There was a problem hiding this comment.
huh. I didn’t consider this. not sure if it’e even worth attempting, but we can remove the recursion and rely on a stack for depth-first traversal. I don’t have a good intuition on performance there, but it would remove any concern for surprising memory behavior and stack overflow. my guess is that holding onto the closure until the cycle collector is not a significant issue.
I think I prefer what you wrote here, so this comment may be pointless.
fun fact: you are very close to the Y combinator here, and I’m curious if the held reference would apply in that case or not.
$to_inspect = array( array( &$block_attributes, 512 ) );
while ( $to_inspect ) {
$frame = array_pop( $to_inspect );
$current = &$frame[0];
$remaining_depth = $frame[1];
foreach ( $current as &$item ) {
if ( is_float( $item ) && ! is_finite( $item ) ) {
$item = null;
} elseif ( is_array( $item ) && $remaining_depth > 1 ) {
$to_inspect[] = array( &$item, $remaining_depth - 1 );
}
}
}
$encoded_attributes = wp_json_encode( … );Codex claimed to have instrumented PHP and compared this to the recursive version and surprisingly, claims is faster than recursion, but differently so depending on the input data. from around the same speed to twice as fast. but all measurements are reasonable, so I do not consider this a knock against the recursive version.
What I would evaluate is whether the potential for stack overflow and the complication of the captured closure (which I think bears no significant or measurable impact on the code) is worth guarding against. this iterative approach mutates the input, which is a knock against it
There was a problem hiding this comment.
🤖 Comment by Claude Opus 5.5
The recursive version has been kept, for the reasons noted: the iterative one changes the attributes in place through references, which would also change any variable of the caller that an attribute refers to (see test_serialize_block_attributes_non_finite_number_reference()). As the depth thread notes, the arrays walked here are no deeper than json_encode() already accepted, so the recursion is bounded at 512 levels and isn't a stack overflow risk in practice.
On the Y combinator: if the closure received itself as an argument rather than capturing a reference to its own variable, there would be no cycle, so the closure would be freed as soon as it goes out of scope, without needing to set the variable to null. That would come at some cost to readability, though, compared with the explicit null assignment.
A review asked whether retrying only for `JSON_ERROR_INF_OR_NAN` could miss attributes nested too deeply, if encoding stopped at the INF or NAN before reaching them. It doesn't stop: `json_encode()` carries on past an INF or NAN value, so a later depth or recursion error is reported in its place. That makes the arrays walked by the retry no deeper than `json_encode()` allows, and the depth limit only a safeguard. Say so in the comment, and add a test with INF next to attributes nested too deeply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Invalid UTF-8 in an attribute name or value made `json_encode()` fail, and `wp_json_encode()` then retried with the strings repaired by `_wp_json_convert_string()`, which turns each invalid byte into a question mark. That is indistinguishable from a real one. Pass `JSON_INVALID_UTF8_SUBSTITUTE` instead, so the invalid bytes are replaced with the replacement character U+FFFD, which marks them as such, without the failed first pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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<!-- wp:paragraph {"0":"a"} /-->, is re-serialized as<!-- wp:paragraph ["a"] /-->, 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<!-- wp:paragraph {"align":"wide","ratio":null} /-->. 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.Trac ticket: https://core.trac.wordpress.org/ticket/66368
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