From 0a2dddc3f2ad5e334141682de9529b9f04b76f04 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 5 Nov 2026 16:23:10 -0700 Subject: [PATCH 1/7] Code Quality: Define the array shape for block type selectors. `WP_Block_Type::$selectors` was documented as a plain `array`. Its shape follows the `selectors` schema in `block.json`: an optional `root` selector string, plus selectors for block support features (such as `color` or `typography`), each either a selector string or an array of selectors keyed by subfeature (such as `root` or `text`). Any feature key is allowed, since third-party blocks and the `states` and `filter` keys are not covered by the schema's listed properties. Document this as `array>`, with the more specific `array{ root?: string, ...> }` in `@phpstan-` tags, on the property, on the `selectors` argument to the constructor, on the return value of `WP_Theme_JSON::get_block_selectors()`, on the `$block_selectors` parameter of `wp_get_state_style_groups()`, and on the `selectors` key of the `$metadata` parameter of `WP_Theme_JSON::get_feature_declarations_for_node()`. Also document the `$block_type` parameter of `WP_Theme_JSON::get_block_selectors()` as `WP_Block_Type` rather than `object`, since it comes from the block type registry and is passed to `wp_get_block_css_selector()`. In `WP_Theme_JSON::get_block_selectors()`, the default selectors are now built by prepending the `root` selector to the feature selectors, rather than adding the feature selectors after it. The keys and their order are unchanged, but a feature named `root` in a subclass's `BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS` can no longer replace the root selector string with an array. This resolves 9 PHPStan errors, including `missingType.iterableValue` on the property and the two functions, `return.type` for `mixed` returned from `wp_get_block_css_selector()` and `WP_Theme_JSON::get_block_selectors()`, and `argument.type` for `mixed` passed to `wp_add_state_style_group()`. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7a12dabb-d901-4983-86e2-a9cbfedc1158 --- src/wp-includes/block-supports/states.php | 6 ++++-- src/wp-includes/class-wp-block-type.php | 9 +++++++-- src/wp-includes/class-wp-theme-json.php | 16 +++++++++------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index 2a80c26db420c..37db91b38e6d6 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -314,9 +314,11 @@ function wp_add_state_style_group( &$groups, $selector, $style ) { * * @since 7.1.0 * - * @param array $state_style State style object. - * @param array $block_selectors Block selectors metadata. + * @param array $state_style State style object. + * @param array> $block_selectors Block selectors metadata. * @return array[] Selector/style groups. + * + * @phpstan-param array{ root?: string, ...> } $block_selectors */ function wp_get_state_style_groups( $state_style, $block_selectors ) { $groups = array(); diff --git a/src/wp-includes/class-wp-block-type.php b/src/wp-includes/class-wp-block-type.php index 811e65c6ab390..d69b0bf8178c9 100644 --- a/src/wp-includes/class-wp-block-type.php +++ b/src/wp-includes/class-wp-block-type.php @@ -137,8 +137,13 @@ class WP_Block_Type { /** * Custom CSS selectors for theme.json style generation. * + * The `root` key holds the selector for the block itself. Each other key is a block support + * feature (such as `color` or `typography`) whose value is either a selector string, or an + * array of selectors keyed by subfeature (such as `root` or `text`). + * * @since 6.3.0 - * @var array + * @var array> + * @phpstan-var array{ root?: string, ...> } */ public $selectors = array(); @@ -327,7 +332,7 @@ class WP_Block_Type { * @type string|null $textdomain The translation textdomain. * @type array[] $styles Alternative block styles. * @type array[] $variations Block variations. - * @type array $selectors Custom CSS selectors for theme.json style generation. + * @type array> $selectors Custom CSS selectors for theme.json style generation. * @type array|null $supports Supported features. * @type array|null $example Structured data for the block preview. * @type callable|null $render_callback Block type render callback. diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 9027c779a7eb3..f4c5196901c5a 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -5993,24 +5993,26 @@ private static function resolve_custom_css_format( $tree ) { * * @since 6.3.0 * - * @param object $block_type The block type. - * @param string $root_selector The block's root selector. - * @return array The custom selectors set by the block. + * @param WP_Block_Type $block_type The block type. + * @param string $root_selector The block's root selector. + * @return array> The custom selectors set by the block. + * + * @phpstan-return array{ root?: string, ...> } */ protected static function get_block_selectors( $block_type, $root_selector ) { if ( ! empty( $block_type->selectors ) ) { return $block_type->selectors; } - $selectors = array( 'root' => $root_selector ); + $feature_selectors = array(); foreach ( static::BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS as $key => $feature ) { $feature_selector = wp_get_block_css_selector( $block_type, $key ); if ( null !== $feature_selector ) { - $selectors[ $feature ] = array( 'root' => $feature_selector ); + $feature_selectors[ $feature ] = array( 'root' => $feature_selector ); } } - return $selectors; + return array( 'root' => $root_selector ) + $feature_selectors; } /** @@ -6057,7 +6059,7 @@ protected static function get_block_element_selectors( $root_selector ) { * @return array The style declarations for the node's features with custom * selectors, keyed by selector. * - * @phpstan-param array{ selectors?: array> } $metadata + * @phpstan-param array{ selectors?: array{ root?: string, ...> } } $metadata * @phpstan-return array> */ protected function get_feature_declarations_for_node( $metadata, &$node ) { From 3f07f7bb70910e030eace2ccd286a0ab8ced8bce Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 6 Nov 2026 00:54:47 -0700 Subject: [PATCH 2/7] Code Quality: Type the state style keys in `wp_get_state_style_groups()`. The `$state_style` parameter is a theme.json style object, keyed by style feature (such as `color` or `typography`), and each key is looked up in `$block_selectors`. Its values come from theme.json and are checked by the function itself, so they stay `mixed`. This resolves the `missingType.iterableValue` PHPStan error on the parameter. The caller, `wp_get_block_state_style_rules()`, narrows each state style from `mixed` only with `is_array()`, so it now reports an `argument.type` error for passing `array`, which is a more accurate account of the untyped theme.json data. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7a12dabb-d901-4983-86e2-a9cbfedc1158 --- src/wp-includes/block-supports/states.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index 37db91b38e6d6..809072fd5612e 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -314,7 +314,7 @@ function wp_add_state_style_group( &$groups, $selector, $style ) { * * @since 7.1.0 * - * @param array $state_style State style object. + * @param array $state_style State style object. * @param array> $block_selectors Block selectors metadata. * @return array[] Selector/style groups. * From b6caa4d4c5eebbb9a62c36d48f004d663f1e63bf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 6 Nov 2026 10:07:29 -0700 Subject: [PATCH 3/7] Code Quality: Define the array shape for viewport media queries. `WP_Theme_JSON::get_viewport_media_queries()` only ever returns the `@mobile`, `@tablet`, and `@desktop` keys, each holding a non-empty `@media` query string, so document that in a `@phpstan-return` array shape. Without it, callers see each media query as `mixed`. The Navigation block passes them to `wp_style_engine_get_stylesheet_from_css_rules()` as the `rules_group`, which expects a string. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 32212707-a7f1-4bca-bef2-7f3b4982950b --- src/wp-includes/class-wp-theme-json.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index f4c5196901c5a..45660e3ea4e23 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -742,7 +742,13 @@ class WP_Theme_JSON { * * @type bool $include_desktop Whether to include the desktop media query. Default false. * } - * @return array Responsive media queries. + * @return array Responsive media queries, keyed by style-state name. + * + * @phpstan-return array{ + * '@mobile'?: non-falsy-string, + * '@tablet'?: non-falsy-string, + * '@desktop'?: non-falsy-string, + * } */ public static function get_viewport_media_queries( $viewport_settings = null, $options = array() ) { $breakpoints = self::sanitize_viewport_settings( $viewport_settings ); From 9b534197302a60dafb2c7178158b1b1589517244 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 6 Nov 2026 11:44:32 -0700 Subject: [PATCH 4/7] Docs: Describe the `states` key of block type selectors. The `WP_Block_Type::$selectors` description said that every key other than `root` is a block support feature, but the `states` key instead holds the selectors for the block's custom states, keyed by state name (such as `-current`). Also realign the `@type` rows of the `$args` hash in the constructor's docblock, which the longer type of the `selectors` argument had pushed out of line. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7a12dabb-d901-4983-86e2-a9cbfedc1158 --- src/wp-includes/class-wp-block-type.php | 66 +++++++++++++------------ 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/src/wp-includes/class-wp-block-type.php b/src/wp-includes/class-wp-block-type.php index d69b0bf8178c9..921b83c94d0a9 100644 --- a/src/wp-includes/class-wp-block-type.php +++ b/src/wp-includes/class-wp-block-type.php @@ -139,7 +139,9 @@ class WP_Block_Type { * * The `root` key holds the selector for the block itself. Each other key is a block support * feature (such as `color` or `typography`) whose value is either a selector string, or an - * array of selectors keyed by subfeature (such as `root` or `text`). + * array of selectors keyed by subfeature (such as `root` or `text`). The exception is the + * `states` key, which holds the selectors for the block's custom states, keyed by state + * name (such as `-current`). * * @since 6.3.0 * @var array> @@ -316,37 +318,37 @@ class WP_Block_Type { * Optional. Array or string of arguments for registering a block type. Any arguments may be defined, * however the ones described below are supported by default. Default empty array. * - * @type string $api_version Block API version. - * @type string $title Human-readable block type label. - * @type string|null $category Block type category classification, used in - * search interfaces to arrange block types by category. - * @type string[]|null $parent Setting parent lets a block require that it is only - * available when nested within the specified blocks. - * @type string[]|null $ancestor Setting ancestor makes a block available only inside the specified - * block types at any position of the ancestor's block subtree. - * @type string[]|null $allowed_blocks Limits which block types can be inserted as children of this block type. - * @type string|null $icon Block type icon. - * @type string $description A detailed block type description. - * @type string[] $keywords Additional keywords to produce block type as - * result in search interfaces. - * @type string|null $textdomain The translation textdomain. - * @type array[] $styles Alternative block styles. - * @type array[] $variations Block variations. - * @type array> $selectors Custom CSS selectors for theme.json style generation. - * @type array|null $supports Supported features. - * @type array|null $example Structured data for the block preview. - * @type callable|null $render_callback Block type render callback. - * @type callable|null $variation_callback Block type variations callback. - * @type array|null $attributes Block type attributes property schemas. - * @type string[] $uses_context Context values inherited by blocks of this type. - * @type string[]|null $provides_context Context provided by blocks of this type. - * @type string[] $block_hooks Block hooks. - * @type string[] $editor_script_handles Block type editor only script handles. - * @type string[] $script_handles Block type front end and editor script handles. - * @type string[] $view_script_handles Block type front end only script handles. - * @type string[] $editor_style_handles Block type editor only style handles. - * @type string[] $style_handles Block type front end and editor style handles. - * @type string[] $view_style_handles Block type front end only style handles. + * @type string $api_version Block API version. + * @type string $title Human-readable block type label. + * @type string|null $category Block type category classification, used in + * search interfaces to arrange block types by category. + * @type string[]|null $parent Setting parent lets a block require that it is only + * available when nested within the specified blocks. + * @type string[]|null $ancestor Setting ancestor makes a block available only inside the specified + * block types at any position of the ancestor's block subtree. + * @type string[]|null $allowed_blocks Limits which block types can be inserted as children of this block type. + * @type string|null $icon Block type icon. + * @type string $description A detailed block type description. + * @type string[] $keywords Additional keywords to produce block type as + * result in search interfaces. + * @type string|null $textdomain The translation textdomain. + * @type array[] $styles Alternative block styles. + * @type array[] $variations Block variations. + * @type array> $selectors Custom CSS selectors for theme.json style generation. + * @type array|null $supports Supported features. + * @type array|null $example Structured data for the block preview. + * @type callable|null $render_callback Block type render callback. + * @type callable|null $variation_callback Block type variations callback. + * @type array|null $attributes Block type attributes property schemas. + * @type string[] $uses_context Context values inherited by blocks of this type. + * @type string[]|null $provides_context Context provided by blocks of this type. + * @type string[] $block_hooks Block hooks. + * @type string[] $editor_script_handles Block type editor only script handles. + * @type string[] $script_handles Block type front end and editor script handles. + * @type string[] $view_script_handles Block type front end only script handles. + * @type string[] $editor_style_handles Block type editor only style handles. + * @type string[] $style_handles Block type front end and editor style handles. + * @type string[] $view_style_handles Block type front end only style handles. * } */ public function __construct( $block_type, $args = array() ) { From 1ad8ddd3d25d7ef818f65dd9d2560b93d6014ecb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 6 Nov 2026 11:44:45 -0700 Subject: [PATCH 5/7] Tests: Cover the selectors returned by `WP_Theme_JSON::get_block_selectors()`. Add tests that: * The default selectors for a block without custom selectors list the root selector first, followed by the feature selectors in the order of `BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS`. * Custom selectors set by the block are returned as they are. * A feature named `root`, in a subclass that overrides `BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS`, does not replace the root selector string. Building the default selectors with `$feature_selectors['root'] ??= $root_selector` instead fails the first and third tests. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7a12dabb-d901-4983-86e2-a9cbfedc1158 --- tests/phpunit/tests/theme/wpThemeJson.php | 114 ++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 56c448ee1c43a..95428cb166478 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -10584,4 +10584,118 @@ public function test_get_styles_for_block_variation_custom_css_uses_css_feature_ $this->assertStringContainsString( '{:root :where(.custom-p.is-style-fancy){color:blue;}}', $actual ); $this->assertStringNotContainsString( ':root :where(p.is-style-fancy){color:', $actual ); } + + /** + * Tests that the default selectors for a block without custom selectors list the root + * selector first, followed by the feature selectors in the order of + * {@see WP_Theme_JSON::BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS}. + * + * @ticket 65817 + * + * @covers WP_Theme_JSON::get_block_selectors + */ + public function test_get_block_selectors_builds_default_selectors_with_root_first() { + $block_type = new WP_Block_Type( + 'test/selectors', + array( + 'supports' => array( + 'typography' => array( '__experimentalSelector' => '.t' ), + 'color' => array( '__experimentalSelector' => '.c' ), + ), + ) + ); + + $this->assertSame( + array( + 'root' => '.wp-block-test-selectors', + 'color' => array( 'root' => '.wp-block-test-selectors .c' ), + 'typography' => array( 'root' => '.wp-block-test-selectors .t' ), + ), + $this->get_block_selectors( WP_Theme_JSON::class, $block_type, '.wp-block-test-selectors' ) + ); + } + + /** + * Tests that custom selectors set by the block are returned as they are. + * + * @ticket 65817 + * + * @covers WP_Theme_JSON::get_block_selectors + */ + public function test_get_block_selectors_returns_custom_selectors() { + $selectors = array( + 'color' => array( 'text' => '.custom-text' ), + 'root' => '.custom-root', + 'states' => array( '-current' => '.custom-root.is-current' ), + ); + $block_type = new WP_Block_Type( + 'test/selectors', + array( + 'selectors' => $selectors, + 'supports' => array( + 'color' => array( '__experimentalSelector' => '.c' ), + ), + ) + ); + + $this->assertSame( + $selectors, + $this->get_block_selectors( WP_Theme_JSON::class, $block_type, '.custom-root' ) + ); + } + + /** + * Tests that a feature named `root` does not replace the root selector string in the + * default selectors. + * + * {@see WP_Theme_JSON::get_block_selectors()} reads the feature level selectors with + * `static::`, so a subclass can override them. + * + * @ticket 65817 + * + * @covers WP_Theme_JSON::get_block_selectors + */ + public function test_get_block_selectors_keeps_root_selector_string() { + $subclass = get_class( + new class() extends WP_Theme_JSON { + const BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS = array( + 'color' => 'root', + 'typography' => 'typography', + ); + } + ); + $block_type = new WP_Block_Type( + 'test/selectors', + array( + 'supports' => array( + 'color' => array( '__experimentalSelector' => '.c' ), + 'typography' => array( '__experimentalSelector' => '.t' ), + ), + ) + ); + + $this->assertSame( + array( + 'root' => '.wp-block-test-selectors', + 'typography' => array( 'root' => '.wp-block-test-selectors .t' ), + ), + $this->get_block_selectors( $subclass, $block_type, '.wp-block-test-selectors' ) + ); + } + + /** + * Calls the protected {@see WP_Theme_JSON::get_block_selectors()} method. + * + * @param string $class_name The name of WP_Theme_JSON or a subclass of it. + * @param WP_Block_Type $block_type The block type. + * @param string $root_selector The block's root selector. + * @return mixed The selectors for the block, as returned through reflection. + */ + private function get_block_selectors( string $class_name, WP_Block_Type $block_type, string $root_selector ) { + $reflection = new ReflectionMethod( $class_name, 'get_block_selectors' ); + if ( PHP_VERSION_ID < 80100 ) { + $reflection->setAccessible( true ); + } + return $reflection->invoke( null, $block_type, $root_selector ); + } } From aabff4e24ffa091138fc2150461ae2dec12af2c2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 6 Nov 2026 12:02:12 -0700 Subject: [PATCH 6/7] Tests: Narrow the class name type in the `get_block_selectors()` test helper. The helper calls `get_block_selectors()` on `WP_Theme_JSON` or a subclass of it, so document `$class_name` as `class-string` rather than `string`. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7a12dabb-d901-4983-86e2-a9cbfedc1158 --- tests/phpunit/tests/theme/wpThemeJson.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 95428cb166478..e1222f17e68c6 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -10686,9 +10686,9 @@ public function test_get_block_selectors_keeps_root_selector_string() { /** * Calls the protected {@see WP_Theme_JSON::get_block_selectors()} method. * - * @param string $class_name The name of WP_Theme_JSON or a subclass of it. - * @param WP_Block_Type $block_type The block type. - * @param string $root_selector The block's root selector. + * @param class-string $class_name The name of WP_Theme_JSON or a subclass of it. + * @param WP_Block_Type $block_type The block type. + * @param string $root_selector The block's root selector. * @return mixed The selectors for the block, as returned through reflection. */ private function get_block_selectors( string $class_name, WP_Block_Type $block_type, string $root_selector ) { From df43b9a7cac26d85bdbc11a2aa7259732d7133df Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 6 Nov 2026 12:19:21 -0700 Subject: [PATCH 7/7] Tests: Keep the `get_block_selectors()` subclass out of the blocks metadata cache. The test for a feature named `root` creates an anonymous subclass of `WP_Theme_JSON` to get its class name. Creating it ran the `WP_Theme_JSON` constructor, which calls `static::get_blocks_metadata()` and fills the blocks metadata cache that the subclass shares with `WP_Theme_JSON`. That cached metadata used the subclass's feature level selectors, where `color` maps to `root`, for every registered block not already cached. As a result, later tests in the same process could have seen core blocks without their color feature selectors, depending on test order. Give the subclass an empty constructor, so that creating it leaves the cache alone. Co-Authored-By: Claude Opus 5.5 (medium effort) Claude-Code-Session-Id: 7a12dabb-d901-4983-86e2-a9cbfedc1158 --- tests/phpunit/tests/theme/wpThemeJson.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index e1222f17e68c6..70d2838984e3b 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -10649,7 +10649,9 @@ public function test_get_block_selectors_returns_custom_selectors() { * default selectors. * * {@see WP_Theme_JSON::get_block_selectors()} reads the feature level selectors with - * `static::`, so a subclass can override them. + * `static::`, so a subclass can override them. The subclass has an empty constructor, so + * that creating it does not fill the shared blocks metadata cache with its feature level + * selectors, which would affect later tests. * * @ticket 65817 * @@ -10662,6 +10664,8 @@ public function test_get_block_selectors_keeps_root_selector_string() { 'color' => 'root', 'typography' => 'typography', ); + + public function __construct() {} } ); $block_type = new WP_Block_Type(