Skip to content

Blocks: Always serialize block attributes as a JSON object, matching serializeAttributes() - #14758

Open
westonruter wants to merge 23 commits into
WordPress:trunkfrom
westonruter:improve/json-encode-serialize-block-attributes-types
Open

westonruter wants to merge 23 commits into
WordPress:trunkfrom
westonruter:improve/json-encode-serialize-block-attributes-types

Conversation

@westonruter

Copy link
Copy Markdown
Member

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

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

westonruter and others added 21 commits October 22, 2026 10:33
…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>
@westonruter

westonruter commented Oct 22, 2026 •

Copy link
Copy Markdown
Member Author

🤖 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 e29e51d, 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

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

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

@westonruter
westonruter marked this pull request as ready for review October 22, 2026 20:13
@westonruter
westonruter requested a review from dmsnell October 22, 2026 20:13
@github-actions

github-actions Bot commented Oct 22, 2026 •

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.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter, dmsnell.

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


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

@westonruter
westonruter requested a review from sirreal October 22, 2026 20:13
* 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 );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn’t an array/list of attributes be illegal here anyway? is there a legitimate way to store numeric indices as attribute keys?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

https://3v4l.org/FEHbL#v8.5.11

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right. I guess my implied question was whether we should reject these entirely if we get a numeric list rather than an associative array

@westonruter westonruter Oct 22, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you think we should just return {} in the case of a list?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@westonruter

Copy link
Copy Markdown
Member Author

@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 esc_html_e() calls inside of the JSON inside of the HTML comments. So I've opened WordPress/ipsum#172 to make this more robust by using actual JSON encoding via serialize_block_attributes().

@dmsnell dmsnell left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is long-overdue, and while I think it needs something to harmonize with the work in #8789, it definitely has value on its own.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 from json_encode(), but three from wp_scrub_utf8().
  • If a site has added a legacy charset like ISO-8859-1 to mb_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 only ASCII, UTF-8, so this seems unlikely to matter.


if ( ! is_array( $value ) || $depth <= 0 ) {
return $value;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry. I wanted to apply your feedback quickly before following more deeply in person to evaluate further. Bot replies suspended.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

westonruter and others added 2 commits October 22, 2026 15:57
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>
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.

2 participants