From cc60b9d2ccae09bab2307af0137fa477f45978e3 Mon Sep 17 00:00:00 2001 From: margolisj Date: Thu, 20 Aug 2026 07:28:53 +0200 Subject: [PATCH 1/2] Editor: Allow the block parser to preserve empty object attributes. 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. --- src/wp-includes/blocks.php | 36 +++ src/wp-includes/class-wp-block-parser.php | 118 ++++++- tests/phpunit/tests/blocks/serialize.php | 81 +++++ tests/phpunit/tests/blocks/wpBlockParser.php | 324 +++++++++++++++++++ 4 files changed, 558 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 4a048c80baf3e..1c36601504e73 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -2559,6 +2559,42 @@ function parse_blocks( $content ) { return $parser->parse( $content ); } +/** + * Parses blocks while preserving nested empty JSON object attributes. + * + * An attribute written as `{}` decodes to an empty PHP array like any other JSON object, + * and is therefore re-serialized as `[]`. This is only a problem for code that parses + * stored markup and writes it back, so preservation is limited to the two workflows that + * do: the Block Hooks algorithm and block content filtering through KSES. + * + * Nested empty objects are returned as empty stdClass instances. Every other value keeps + * the shape the default parse path produces. + * + * Custom block parsers that do not implement `parse_with_options()` fall back to their + * existing parse behavior. + * + * @since 7.2.0 + * @access private + * + * @param string $content Post content. + * @return array[] Array of parsed block objects. + */ +function _wp_parse_blocks_preserving_empty_object_attributes( $content ) { + /** This filter is documented in wp-includes/blocks.php */ + $parser_class = apply_filters( 'block_parser_class', 'WP_Block_Parser' ); + + $parser = new $parser_class(); + + if ( method_exists( $parser, 'parse_with_options' ) ) { + return $parser->parse_with_options( + $content, + array( 'preserve_empty_object_attributes' => true ) + ); + } + + return $parser->parse( $content ); +} + /** * Parses dynamic blocks out of `post_content` and re-renders them. * diff --git a/src/wp-includes/class-wp-block-parser.php b/src/wp-includes/class-wp-block-parser.php index ea66e3b51d38d..2f7803dc24b04 100644 --- a/src/wp-includes/class-wp-block-parser.php +++ b/src/wp-includes/class-wp-block-parser.php @@ -48,6 +48,14 @@ class WP_Block_Parser { */ public $stack; + /** + * Parse options for the current parse + * + * @since 7.2.0 + * @var array + */ + protected $options = array(); + /** * Parses a document and returns a list of block structures * @@ -73,6 +81,35 @@ public function parse( $document ) { return $this->output; } + /** + * Parses a document with parse options and returns a list of block structures. + * + * Separate from {@see WP_Block_Parser::parse()} to leave that method's signature + * unchanged, since a parser supplied through the `block_parser_class` filter may + * subclass this class and override it. Delegating to `parse()` rather than + * reimplementing it keeps any such override in effect. + * + * @since 7.2.0 + * + * @param string $document Input document being parsed. + * @param array $options Optional. Parse options. Supports the + * `preserve_empty_object_attributes` key, which keeps a + * nested empty JSON object attribute as an empty object + * rather than collapsing it to an empty array. Anything + * that is not an array is ignored. Default empty array. + * @return array[] + */ + public function parse_with_options( $document, $options = array() ) { + $this->options = is_array( $options ) ? $options : array(); + + try { + return $this->parse( $document ); + } finally { + // Options apply to a single parse only. + $this->options = array(); + } + } + /** * Processes the next token from the input document * and returns whether to proceed eating more tokens @@ -277,7 +314,7 @@ public function next_token() { * are associative arrays. If we use `array()` we get a JSON `[]` */ $attrs = $has_attrs - ? json_decode( $matches['attrs'][0], /* as-associative */ true ) + ? $this->parse_block_attributes( $matches['attrs'][0] ) : array(); /* @@ -387,6 +424,85 @@ public function add_block_from_stack( $end_offset = null ) { $this->output[] = (array) $stack_top->block; } + + /** + * Decodes a block's attribute JSON. + * + * @since 7.2.0 + * + * @param string $json Raw attribute JSON from the block delimiter. + * @return array|null Decoded attributes, or null on invalid JSON. + */ + private function parse_block_attributes( $json ) { + if ( + empty( $this->options['preserve_empty_object_attributes'] ) + /* + * An attribute string with no `{}` token cannot contain an empty object, so + * there is nothing to preserve and the historical decode is used. Most + * attributes fall in that group, which keeps their cost unchanged. The + * character class covers the whitespace JSON permits between the braces. + * + * A `{}` inside a string value, as in `{"tpl":"{}"}`, only leads to a walk + * that finds nothing to change, so this is an optimization, not a fork. + */ + || ! preg_match( '/\{[ \t\r\n]*\}/', $json ) + ) { + // Default (historical) behavior: objects and arrays both decode to arrays. + return json_decode( $json, /* associative */ true ); + } + + $decoded = json_decode( $json, /* associative */ false ); + if ( JSON_ERROR_NONE !== json_last_error() ) { + return null; + } + + return self::normalize_block_attributes( $decoded, /* is_attribute_root */ true ); + } + + /** + * Converts a json_decode(..., false) result into the parsed attribute shape, + * keeping only nested empty objects as objects. + * + * Every other JSON object becomes a PHP array, matching the default parse path. An + * empty object holds no keys and no strings, so nothing downstream needs to read + * into it or sanitize it; a populated object would hide its contents from code that + * walks arrays. + * + * wp_json_encode() emits `{}` for an empty object and `[]` for an empty array, so + * the distinction survives serialization without a marker or a restore step. + * + * @since 7.2.0 + * + * @param mixed $value Decoded value (stdClass, array, or scalar). + * @param bool $is_attribute_root Whether $value is the top-level attribute container, + * which always becomes an array so that empty + * attributes keep being dropped on serialization. + * @return mixed The normalized value. + */ + private static function normalize_block_attributes( $value, $is_attribute_root = false ) { + if ( $value instanceof stdClass ) { + $properties = get_object_vars( $value ); + + if ( ! $is_attribute_root && empty( $properties ) ) { + return $value; + } + + $normalized = array(); + foreach ( $properties as $key => $child_value ) { + $normalized[ $key ] = self::normalize_block_attributes( $child_value ); + } + + return $normalized; + } + + if ( is_array( $value ) ) { + foreach ( $value as $key => $child_value ) { + $value[ $key ] = self::normalize_block_attributes( $child_value ); + } + } + + return $value; // Scalars and null pass through unchanged. + } } /** diff --git a/tests/phpunit/tests/blocks/serialize.php b/tests/phpunit/tests/blocks/serialize.php index 5d119e655babc..81a9ebee33180 100644 --- a/tests/phpunit/tests/blocks/serialize.php +++ b/tests/phpunit/tests/blocks/serialize.php @@ -358,4 +358,85 @@ public function test_traverse_and_serialize_blocks_do_not_insert_in_empty_parent $this->assertSame( $markup, $actual ); } + + /** + * A nested empty object survives the round trip, and an empty array stays an array. + * + * No serializer change is involved: wp_json_encode() emits `{}` for an empty object + * and `[]` for an empty array, so the parsed representation carries the distinction + * on its own. + * + * @ticket 63325 + * + * @dataProvider data_empty_object_round_trip + * + * @covers ::serialize_blocks + * + * @param string $markup Block markup that must survive unchanged. + */ + public function test_empty_objects_survive_the_round_trip( $markup ) { + $actual = serialize_blocks( _wp_parse_blocks_preserving_empty_object_attributes( $markup ) ); + + $this->assertSame( $markup, $actual ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_empty_object_round_trip() { + return array( + 'empty object beside empty array' => array( '' ), + 'deeply nested' => array( '' ), + 'inside an array' => array( '' ), + 'populated sibling' => array( '' ), + 'inner blocks' => array( '' ), + ); + } + + /** + * The default parse path is unchanged, so it still collapses `{}` to `[]`. + * + * @ticket 63325 + * + * @covers ::serialize_blocks + */ + public function test_default_parse_path_still_collapses_empty_objects() { + $actual = serialize_blocks( parse_blocks( '' ) ); + + $this->assertSame( '', $actual ); + } + + /** + * Empty top-level attributes keep being dropped, as they always have been. + * + * @ticket 63325 + * + * @covers ::serialize_blocks + */ + public function test_empty_top_level_attributes_are_still_dropped() { + $actual = serialize_blocks( _wp_parse_blocks_preserving_empty_object_attributes( '' ) ); + + $this->assertSame( '', $actual ); + } + + /** + * Preservation is deliberately limited to empty objects. + * + * An object whose keys are the sequential strings "0", "1", ... also encodes as a + * JSON array, but restoring that would mean tracking the type of every value rather + * than the one shape this ticket is about. It stays out of scope. + * + * @ticket 63325 + * + * @covers ::serialize_blocks + */ + public function test_numeric_keyed_objects_are_not_preserved() { + $actual = serialize_blocks( + _wp_parse_blocks_preserving_empty_object_attributes( '' ) + ); + + $this->assertSame( '', $actual ); + } } diff --git a/tests/phpunit/tests/blocks/wpBlockParser.php b/tests/phpunit/tests/blocks/wpBlockParser.php index 4523f0ec4ed04..809d47e8ba99e 100644 --- a/tests/phpunit/tests/blocks/wpBlockParser.php +++ b/tests/phpunit/tests/blocks/wpBlockParser.php @@ -114,4 +114,328 @@ protected function pass_parser_fixture_filenames( $filename ) { protected function strip_r( $input ) { return str_replace( "\r", '', $input ); } + + /** + * Parses markup with empty-object preservation and returns the first block's attributes. + * + * @param string $markup Block markup with a single top-level block. + * @return array|null The parsed attributes. + */ + private function parse_attrs_preserving( $markup ) { + $blocks = _wp_parse_blocks_preserving_empty_object_attributes( $markup ); + + return $blocks[0]['attrs']; + } + + /** + * The default parse path must be untouched: an empty object and an empty array + * both decode to an empty PHP array, as they always have. + * + * @ticket 63325 + * + * @covers ::parse_blocks + */ + public function test_default_parse_collapses_empty_objects_to_arrays() { + $blocks = parse_blocks( '' ); + $attrs = $blocks[0]['attrs']; + + $this->assertSame( array(), $attrs['object'] ); + $this->assertSame( array(), $attrs['array'] ); + } + + /** + * With preservation on, only the empty object becomes an object. + * + * @ticket 63325 + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + */ + public function test_preserving_parse_keeps_only_empty_objects_as_objects() { + $attrs = $this->parse_attrs_preserving( '' ); + + $this->assertInstanceOf( 'stdClass', $attrs['object'] ); + $this->assertSame( array(), get_object_vars( $attrs['object'] ) ); + $this->assertSame( array(), $attrs['array'] ); + } + + /** + * An empty object written with whitespace between the braces is still an empty object. + * + * The preserving path is gated on finding a `{}` token, so the gate has to accept every + * whitespace form JSON permits there rather than only the two-character sequence that + * `JSON.stringify()` happens to emit. + * + * @ticket 63325 + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + * + * @dataProvider data_empty_object_whitespace + * + * @param string $empty_object An empty JSON object, possibly containing whitespace. + */ + public function test_preserving_parse_accepts_json_whitespace_in_empty_objects( $empty_object ) { + $attrs = $this->parse_attrs_preserving( '' ); + + $this->assertInstanceOf( 'stdClass', $attrs['object'] ); + $this->assertSame( array(), get_object_vars( $attrs['object'] ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_empty_object_whitespace() { + return array( + 'no whitespace' => array( '{}' ), + 'space' => array( '{ }' ), + 'tab' => array( "{\t}" ), + 'line feed' => array( "{\n}" ), + 'carriage return' => array( "{\r}" ), + 'all of them' => array( "{ \t\r\n }" ), + ); + } + + /** + * A `{}` sequence inside a string value must not change the parsed result. + * + * The gate on the preserving path is lexical, so markup like `{"tpl":"{}"}` takes the + * slower path even though it holds no empty object. That is allowed to cost a wasted + * walk; it is not allowed to change the value, which must stay a string. + * + * @ticket 63325 + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + * + * @dataProvider data_empty_object_inside_a_string + * + * @param string $value The string value, as written in the attribute JSON. + */ + public function test_empty_object_inside_a_string_stays_a_string( $value ) { + $markup = ''; + $attrs = $this->parse_attrs_preserving( $markup ); + + $this->assertIsString( $attrs['tpl'] ); + $this->assertSame( $value, $attrs['tpl'] ); + + // And the markup is reproduced byte for byte. + $this->assertSame( $markup, serialize_blocks( _wp_parse_blocks_preserving_empty_object_attributes( $markup ) ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_empty_object_inside_a_string() { + return array( + 'no whitespace' => array( '{}' ), + 'space' => array( '{ }' ), + 'beside other text' => array( 'before {} after' ), + ); + } + + /** + * A real empty object and a string that merely looks like one, in the same attributes. + * + * Proves the two are told apart by the parse itself rather than by the lexical gate, + * which only decides whether to look. + * + * @ticket 63325 + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + */ + public function test_empty_object_and_lookalike_string_are_told_apart() { + $attrs = $this->parse_attrs_preserving( '' ); + + $this->assertSame( '{}', $attrs['tpl'] ); + $this->assertInstanceOf( 'stdClass', $attrs['object'] ); + } + + /** + * A populated object still becomes an array, so code that walks attributes with + * array access and array functions keeps working. + * + * @ticket 63325 + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + */ + public function test_preserving_parse_converts_non_empty_objects_to_arrays() { + $attrs = $this->parse_attrs_preserving( '' ); + + $this->assertSame( array( 'enabled' => true ), $attrs['object'] ); + } + + /** + * Only the innermost empty object is an object; its ancestors stay arrays. + * + * @ticket 63325 + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + */ + public function test_preserving_parse_handles_deep_nesting() { + $attrs = $this->parse_attrs_preserving( '' ); + + $this->assertIsArray( $attrs['one'] ); + $this->assertIsArray( $attrs['one']['two'] ); + $this->assertInstanceOf( 'stdClass', $attrs['one']['two']['empty'] ); + } + + /** + * Empty objects are preserved inside JSON arrays too. + * + * @ticket 63325 + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + */ + public function test_preserving_parse_handles_empty_objects_inside_arrays() { + $attrs = $this->parse_attrs_preserving( '' ); + + $this->assertInstanceOf( 'stdClass', $attrs['items'][0] ); + $this->assertSame( array(), $attrs['items'][1] ); + $this->assertInstanceOf( 'stdClass', $attrs['items'][2]['nested'] ); + } + + /** + * The top-level attribute container always becomes an array, so that empty + * attributes keep being dropped on serialization. + * + * @ticket 63325 + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + */ + public function test_preserving_parse_converts_the_attribute_root_to_an_array() { + $this->assertSame( array(), $this->parse_attrs_preserving( '' ) ); + } + + /** + * Malformed attribute JSON behaves identically on both paths. + * + * @ticket 63325 + * + * @dataProvider data_invalid_attribute_json + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + * + * @param string $json Malformed attribute JSON. + */ + public function test_invalid_json_yields_null_attributes_on_both_paths( $json ) { + $markup = ""; + + $this->assertNull( parse_blocks( $markup )[0]['attrs'] ); + $this->assertNull( $this->parse_attrs_preserving( $markup ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_invalid_attribute_json() { + return array( + 'missing value' => array( '{"broken":}' ), + 'unquoted keys' => array( '{not json}' ), + 'trailing comma' => array( '{"a":1,}' ), + ); + } + + /** + * A replacement parser that only implements parse() must keep working. + * + * This is the reason preservation is requested through parse_with_options() + * rather than by adding a parameter to parse(): PHP rejects a subclass that + * declares fewer parameters than its parent, so widening parse() itself would + * fatal for every plugin that overrides it. + * + * @ticket 63325 + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + */ + public function test_replacement_parser_without_parse_with_options_falls_back() { + $filter = static function () { + return 'Tests_Blocks_Legacy_Parser'; + }; + add_filter( 'block_parser_class', $filter ); + + try { + $attrs = $this->parse_attrs_preserving( '' ); + } finally { + remove_filter( 'block_parser_class', $filter ); + } + + // The legacy parser knows nothing about preservation, so the historical shape comes back. + $this->assertSame( array(), $attrs['object'] ); + } + + /** + * A subclass may still override parse() with the one-argument signature. + * + * Widening parse() itself would make every such subclass a fatal error, and + * delegating parse_with_options() to parse() keeps the override in effect even + * when core requests options. + * + * @ticket 63325 + * + * @covers ::_wp_parse_blocks_preserving_empty_object_attributes + */ + public function test_subclass_overriding_parse_is_still_used() { + $filter = static function () { + return 'Tests_Blocks_Subclassed_Parser'; + }; + add_filter( 'block_parser_class', $filter ); + + try { + $blocks = _wp_parse_blocks_preserving_empty_object_attributes( '' ); + } finally { + remove_filter( 'block_parser_class', $filter ); + } + + $this->assertTrue( + Tests_Blocks_Subclassed_Parser::$parse_was_called, + 'The subclass override of parse() should still run.' + ); + // Preservation still applies, because the option is read during the inherited parse. + $this->assertInstanceOf( 'stdClass', $blocks[0]['attrs']['object'] ); + } +} + +/** + * A replacement parser predating parse_with_options(), implementing only parse(). + */ +class Tests_Blocks_Legacy_Parser { + /** + * Parses a document into blocks. + * + * @param string $document Input document being parsed. + * @return array[] + */ + public function parse( $document ) { + $parser = new WP_Block_Parser(); + + return $parser->parse( $document ); + } +} + +/** + * A parser subclass that overrides parse() with the historical signature. + */ +class Tests_Blocks_Subclassed_Parser extends WP_Block_Parser { + /** + * Whether the override ran. + * + * @var bool + */ + public static $parse_was_called = false; + + /** + * Parses a document into blocks. + * + * @param string $document Input document being parsed. + * @return array[] + */ + public function parse( $document ) { + self::$parse_was_called = true; + + return parent::parse( $document ); + } } From 380042d1d2d34a11f8fe7b7a5909d4b4f8c49fac Mon Sep 17 00:00:00 2001 From: margolisj Date: Thu, 20 Aug 2026 07:28:57 +0200 Subject: [PATCH 2/2] Editor: Preserve empty object attributes through Block Hooks and KSES. 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. --- src/wp-includes/blocks.php | 109 ++++++++++-- .../tests/blocks/applyBlockHooksToContent.php | 167 ++++++++++++++++++ .../tests/blocks/filterBlockContent.php | 77 ++++++++ 3 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 tests/phpunit/tests/blocks/filterBlockContent.php diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 1c36601504e73..2ec578ffc8add 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -1025,6 +1025,30 @@ function insert_hooked_blocks( &$parsed_anchor_block, $relative_position, $hooke $hooked_block_types = apply_filters( 'hooked_block_types', $hooked_block_types, $relative_position, $anchor_block_type, $context ); $markup = ''; + + /* + * Nothing below reads or writes anything but the anchor block, so an empty list + * leaves no work to do. Returning here avoids copying the anchor block's attributes + * for the many blocks that have no hooked blocks. + * + * This must run after the `hooked_block_types` filter, which can add a hooked block + * type to an anchor that has none registered. + */ + if ( empty( $hooked_block_types ) ) { + return $markup; + } + + // Filters have always received array-shaped attributes. + $filtered_anchor_block = _wp_get_block_hooks_filter_anchor_block( $parsed_anchor_block ); + + /* + * Read `metadata` through an array cast. On the preserving parse path `"metadata":{}` + * arrives as an empty stdClass, and in PHP an array offset on an object is a fatal + * error, including inside isset() and ??. + */ + $anchor_metadata = (array) ( $parsed_anchor_block['attrs']['metadata'] ?? array() ); + $ignored_hooked_blocks = (array) ( $anchor_metadata['ignoredHookedBlocks'] ?? array() ); + foreach ( $hooked_block_types as $hooked_block_type ) { $parsed_hooked_block = array( 'blockName' => $hooked_block_type, @@ -1046,7 +1070,7 @@ function insert_hooked_blocks( &$parsed_anchor_block, $relative_position, $hooke * @param WP_Block_Template|WP_Post|array $context The block template, template part, post object, * or pattern that the anchor block belongs to. */ - $parsed_hooked_block = apply_filters( 'hooked_block', $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context ); + $parsed_hooked_block = apply_filters( 'hooked_block', $parsed_hooked_block, $hooked_block_type, $relative_position, $filtered_anchor_block, $context ); /** * Filters the parsed block array for a given hooked block. @@ -1062,7 +1086,7 @@ function insert_hooked_blocks( &$parsed_anchor_block, $relative_position, $hooke * @param WP_Block_Template|WP_Post|array $context The block template, template part, post object, * or pattern that the anchor block belongs to. */ - $parsed_hooked_block = apply_filters( "hooked_block_{$hooked_block_type}", $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context ); + $parsed_hooked_block = apply_filters( "hooked_block_{$hooked_block_type}", $parsed_hooked_block, $hooked_block_type, $relative_position, $filtered_anchor_block, $context ); if ( null === $parsed_hooked_block ) { continue; @@ -1070,10 +1094,7 @@ function insert_hooked_blocks( &$parsed_anchor_block, $relative_position, $hooke // It's possible that the filter returned a block of a different type, so we explicitly // look for the original `$hooked_block_type` in the `ignoredHookedBlocks` metadata. - if ( - ! isset( $parsed_anchor_block['attrs']['metadata']['ignoredHookedBlocks'] ) || - ! in_array( $hooked_block_type, $parsed_anchor_block['attrs']['metadata']['ignoredHookedBlocks'], true ) - ) { + if ( ! in_array( $hooked_block_type, $ignored_hooked_blocks, true ) ) { $markup .= serialize_block( $parsed_hooked_block ); } } @@ -1108,6 +1129,9 @@ function set_ignored_hooked_blocks_metadata( &$parsed_anchor_block, $relative_po return ''; } + // Filters have always received array-shaped attributes. + $filtered_anchor_block = _wp_get_block_hooks_filter_anchor_block( $parsed_anchor_block ); + foreach ( $hooked_block_types as $index => $hooked_block_type ) { $parsed_hooked_block = array( 'blockName' => $hooked_block_type, @@ -1117,25 +1141,33 @@ function set_ignored_hooked_blocks_metadata( &$parsed_anchor_block, $relative_po ); /** This filter is documented in wp-includes/blocks.php */ - $parsed_hooked_block = apply_filters( 'hooked_block', $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context ); + $parsed_hooked_block = apply_filters( 'hooked_block', $parsed_hooked_block, $hooked_block_type, $relative_position, $filtered_anchor_block, $context ); /** This filter is documented in wp-includes/blocks.php */ - $parsed_hooked_block = apply_filters( "hooked_block_{$hooked_block_type}", $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context ); + $parsed_hooked_block = apply_filters( "hooked_block_{$hooked_block_type}", $parsed_hooked_block, $hooked_block_type, $relative_position, $filtered_anchor_block, $context ); if ( null === $parsed_hooked_block ) { unset( $hooked_block_types[ $index ] ); } } - $previously_ignored_hooked_blocks = $parsed_anchor_block['attrs']['metadata']['ignoredHookedBlocks'] ?? array(); + /* + * Rebuild `metadata` through an array cast rather than writing into it in place. On + * the preserving parse path `"metadata":{}` arrives as an empty stdClass, and in PHP + * an array offset on an object is a fatal error, assignment included. + */ + $anchor_metadata = (array) ( $parsed_anchor_block['attrs']['metadata'] ?? array() ); + $previously_ignored_hooked_blocks = (array) ( $anchor_metadata['ignoredHookedBlocks'] ?? array() ); - $parsed_anchor_block['attrs']['metadata']['ignoredHookedBlocks'] = array_unique( + $anchor_metadata['ignoredHookedBlocks'] = array_unique( array_merge( $previously_ignored_hooked_blocks, $hooked_block_types ) ); + $parsed_anchor_block['attrs']['metadata'] = $anchor_metadata; + // Markup for the hooked blocks has already been created (in `insert_hooked_blocks`). return ''; } @@ -1237,7 +1269,7 @@ function apply_block_hooks_to_content( $content, $context = null, $callback = 'i }; add_filter( 'hooked_block_types', $suppress_single_instance_blocks, PHP_INT_MAX ); $content = traverse_and_serialize_blocks( - parse_blocks( $content ), + _wp_parse_blocks_preserving_empty_object_attributes( $content ), $before_block_visitor, $after_block_visitor ); @@ -2123,7 +2155,7 @@ function filter_block_content( $text, $allowed_html = 'post', $allowed_protocols $text = preg_replace_callback( '%%', '_filter_block_content_callback', $text ); } - $blocks = parse_blocks( $text ); + $blocks = _wp_parse_blocks_preserving_empty_object_attributes( $text ); foreach ( $blocks as $block ) { $block = filter_block_kses( $block, $allowed_html, $allowed_protocols ); $result .= serialize_block( $block ); @@ -2595,6 +2627,59 @@ function _wp_parse_blocks_preserving_empty_object_attributes( $content ) { return $parser->parse( $content ); } +/** + * Converts preserved empty object attributes back to empty arrays. + * + * Used when exposing an internally preserved parsed block to extension points that + * historically received arrays throughout. + * + * @since 7.2.0 + * @access private + * + * @param mixed $value Attribute value. + * @return mixed The value with empty objects converted to empty arrays. + */ +function _wp_block_attribute_empty_objects_to_arrays( $value ) { + if ( $value instanceof stdClass ) { + return array(); + } + + if ( ! is_array( $value ) ) { + return $value; + } + + foreach ( $value as $key => $child_value ) { + if ( is_array( $child_value ) || $child_value instanceof stdClass ) { + $value[ $key ] = _wp_block_attribute_empty_objects_to_arrays( $child_value ); + } + } + + return $value; +} + +/** + * Returns a parsed block using the historical all-array attribute shape. + * + * The Block Hooks algorithm parses with empty-object preservation enabled, so an anchor + * block handed to a third-party filter would otherwise expose an empty stdClass where + * that filter has always seen an empty array. + * + * @since 7.2.0 + * @access private + * + * @param array $parsed_block A block, in parsed block array format. + * @return array A copy of the block with all-array attributes. + */ +function _wp_get_block_hooks_filter_anchor_block( $parsed_block ) { + if ( empty( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ) { + return $parsed_block; + } + + $parsed_block['attrs'] = _wp_block_attribute_empty_objects_to_arrays( $parsed_block['attrs'] ); + + return $parsed_block; +} + /** * Parses dynamic blocks out of `post_content` and re-renders them. * diff --git a/tests/phpunit/tests/blocks/applyBlockHooksToContent.php b/tests/phpunit/tests/blocks/applyBlockHooksToContent.php index 150560dbaba24..99537867f5c74 100644 --- a/tests/phpunit/tests/blocks/applyBlockHooksToContent.php +++ b/tests/phpunit/tests/blocks/applyBlockHooksToContent.php @@ -191,4 +191,171 @@ public function test_apply_block_hooks_to_content_respect_multiple_false_after_i $actual ); } + + /** + * An empty object attribute on the anchor block must survive hooked block insertion. + * + * The Block Hooks algorithm parses stored markup and writes it back, which is what + * turned `{}` into `[]` before empty-object preservation existed. + * + * @ticket 63325 + */ + public function test_empty_object_attribute_survives_hooked_block_insertion() { + $context = new WP_Block_Template(); + $context->content = ''; + + $actual = apply_block_hooks_to_content( $context->content, $context, 'insert_hooked_blocks' ); + + $this->assertSame( + '', + $actual + ); + } + + /** + * An empty `metadata` object must not break the algorithm that writes to it. + * + * `set_ignored_hooked_blocks_metadata()` and `insert_hooked_blocks()` index into + * `attrs.metadata.ignoredHookedBlocks`. In PHP every array offset against an object + * is fatal -- including inside isset() and ?? -- so an anchor block carrying + * `"metadata":{}` would take down the whole request without the array casts there. + * + * @ticket 63325 + * + * @dataProvider data_object_shaped_metadata + * + * @param string $attributes Anchor block attribute JSON. + */ + public function test_object_shaped_metadata_does_not_fatal( $attributes ) { + $context = new WP_Block_Template(); + $context->content = ""; + + $actual = apply_block_hooks_to_content( $context->content, $context, 'insert_hooked_blocks' ); + + $this->assertStringContainsString( '', $actual ); + } + + /** + * Same shapes, through the visitor that records ignored hooked blocks. + * + * @ticket 63325 + * + * @dataProvider data_object_shaped_metadata + * + * @param string $attributes Anchor block attribute JSON. + */ + public function test_object_shaped_metadata_does_not_fatal_when_setting_metadata( $attributes ) { + $context = new WP_Block_Template(); + $context->content = ""; + + $actual = apply_block_hooks_to_content( $context->content, $context, 'set_ignored_hooked_blocks_metadata' ); + + $this->assertStringContainsString( '"ignoredHookedBlocks":["tests/hooked-block"]', $actual ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_object_shaped_metadata() { + return array( + 'empty metadata object' => array( '{"metadata":{}}' ), + 'empty ignoredHookedBlocks object' => array( '{"metadata":{"ignoredHookedBlocks":{}}}' ), + 'empty object beside real metadata' => array( '{"metadata":{"name":"Test","extra":{}}}' ), + ); + } + + /** + * Filters must keep receiving the historical all-array attribute shape. + * + * The preserved empty object stays in the block that gets serialized, but the copy + * handed to `hooked_block` is converted, so existing callbacks that walk attributes + * with array access are unaffected. + * + * @ticket 63325 + */ + public function test_hooked_block_filter_receives_array_shaped_attributes() { + $anchor_attrs = null; + + $filter = static function ( $parsed_hooked_block, $hooked_block_type, $relative_position, $anchor_block ) use ( &$anchor_attrs ) { + $anchor_attrs = $anchor_block['attrs']; + + return $parsed_hooked_block; + }; + + $context = new WP_Block_Template(); + $context->content = ''; + + add_filter( 'hooked_block', $filter, 10, 4 ); + $actual = apply_block_hooks_to_content( $context->content, $context, 'insert_hooked_blocks' ); + remove_filter( 'hooked_block', $filter, 10 ); + + $this->assertIsArray( $anchor_attrs['layout']['columns'], 'The filter should not see a preserved object.' ); + $this->assertSame( array(), $anchor_attrs['layout']['columns'] ); + + // The serialized markup still carries the empty object. + $this->assertStringContainsString( '"columns":{}', $actual ); + } + + /** + * A hooked block added purely by filter must still be inserted. + * + * `insert_hooked_blocks()` returns early when no hooked block types remain, which is what + * lets it skip normalizing the anchor block's attributes for the majority of blocks. That + * check has to run after the `hooked_block_types` filter: an anchor with nothing registered + * statically starts with an empty list, and a plugin is entitled to fill it. + * + * @ticket 63325 + */ + public function test_filter_can_add_hooked_block_to_anchor_with_no_registered_hooks() { + $anchor_block_type = 'tests/anchor-block-without-registered-hooks'; + + // Nothing is hooked to this anchor, so the list is empty until the filter runs. + $this->assertSame( + array(), + get_hooked_blocks()[ $anchor_block_type ] ?? array(), + 'The anchor block should have no statically registered hooked blocks.' + ); + + $filter = static function ( $hooked_block_types, $relative_position, $anchor ) use ( $anchor_block_type ) { + if ( $anchor_block_type === $anchor && 'after' === $relative_position ) { + $hooked_block_types[] = 'tests/hooked-block'; + } + + return $hooked_block_types; + }; + + $context = new WP_Block_Template(); + $context->content = ""; + + add_filter( 'hooked_block_types', $filter, 10, 3 ); + $actual = apply_block_hooks_to_content( $context->content, $context, 'insert_hooked_blocks' ); + remove_filter( 'hooked_block_types', $filter, 10 ); + + $this->assertSame( + "", + $actual + ); + } + + /** + * An anchor block with no hooked blocks must serialize back unchanged. + * + * Companion to the test above: this is the path that now returns before normalizing the + * anchor block, and a preserved empty object still has to survive it. + * + * @ticket 63325 + */ + public function test_anchor_block_without_hooked_blocks_round_trips() { + $content = ''; + + $context = new WP_Block_Template(); + $context->content = $content; + + $this->assertSame( + $content, + apply_block_hooks_to_content( $content, $context, 'insert_hooked_blocks' ) + ); + } } diff --git a/tests/phpunit/tests/blocks/filterBlockContent.php b/tests/phpunit/tests/blocks/filterBlockContent.php new file mode 100644 index 0000000000000..67eaaae77b907 --- /dev/null +++ b/tests/phpunit/tests/blocks/filterBlockContent.php @@ -0,0 +1,77 @@ +assertSame( $markup, filter_block_content( $markup ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_empty_object_markup() { + return array( + 'empty object and empty array' => array( '' ), + 'inside an array' => array( '' ), + 'inner blocks' => array( '' ), + ); + } + + /** + * Strings inside a populated object must still be sanitized. + * + * filter_block_kses_value() recurses through arrays only, so a populated object + * left as an object would carry its strings past wp_kses(). + * + * @ticket 63325 + */ + public function test_kses_still_sanitizes_strings_beside_a_preserved_empty_object() { + $markup = ''; + + $actual = filter_block_content( $markup ); + + $this->assertStringNotContainsString( 'script', $actual, 'The disallowed tag should have been stripped.' ); + $this->assertStringContainsString( 'strong', $actual, 'The allowed tag should have survived.' ); + $this->assertStringContainsString( '"options":{}', $actual, 'The empty object should have survived.' ); + } + + /** + * The empty object must not reach saved content as anything but `{}`. + * + * Preservation uses no marker key or sentinel value. This guards against one + * being introduced later. + * + * @ticket 63325 + */ + public function test_no_implementation_data_reaches_filtered_content() { + $actual = filter_block_content( '' ); + + $this->assertStringNotContainsString( '__wp', $actual ); + $this->assertStringNotContainsString( 'stdClass', $actual ); + } +}