From b8c5e138be54713a00ee7e25a91df0e90f5581fd Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 20 Oct 2026 17:40:10 +1100 Subject: [PATCH 1/3] Editor: Generate custom CSS for blocks rendered by their parent block. `wp_render_custom_css_support_styles()` runs on `render_block_data`, which is applied in `render_block()` and when `WP_Block::render()` renders a block's own inner blocks. Blocks that a parent parses and renders itself never reach it. For example, `core/navigation` builds a `WP_Block_List` from a `wp_navigation` post and calls `render()` on each block, so the Additional CSS of a Navigation Link was dropped on the front end. Class name generation and stylesheet registration move into a new private helper, `wp_generate_custom_css_class_name()`. `wp_render_custom_css_class_name()` runs on `render_block` for every block, and now calls the helper when the block attributes carry no `wp-custom-css-` class. The class name is a deterministic hash, so a block that passes through both filters still gets one class and one rule. Backports https://github.com/WordPress/gutenberg/pull/82590. See #66345. --- src/wp-includes/block-supports/custom-css.php | 127 ++++++++----- .../wpRenderCustomCssClassName.php | 175 ++++++++++++++++++ 2 files changed, 260 insertions(+), 42 deletions(-) diff --git a/src/wp-includes/block-supports/custom-css.php b/src/wp-includes/block-supports/custom-css.php index f5318d075f36e..2871a3c38a768 100644 --- a/src/wp-includes/block-supports/custom-css.php +++ b/src/wp-includes/block-supports/custom-css.php @@ -124,12 +124,13 @@ function wp_process_custom_css_state_entries( $state_entries, $selector ) { } /** - * Render the custom CSS stylesheet and add class name to block as required. + * Generates the custom CSS class name for a block and registers its stylesheet. * - * @since 7.0.0 + * @since 7.2.0 + * @access private * * @param array $parsed_block The parsed block. - * @return array The same parsed block with custom CSS class name added if appropriate. + * @return string|null The generated class name, or null if the block has no custom CSS to render. * * @phpstan-param array{ * blockName: string|null, @@ -143,51 +144,32 @@ function wp_process_custom_css_state_entries( $state_entries, $selector ) { * }, * ... * } $parsed_block - * @phpstan-return array{ - * blockName: string|null, - * attrs: array{ - * className?: string, - * style?: array{ - * css?: string, - * ... - * }, - * ... - * }, - * ... - * } */ -function wp_render_custom_css_support_styles( $parsed_block ) { +function wp_generate_custom_css_class_name( $parsed_block ) { $style = $parsed_block['attrs']['style'] ?? null; if ( empty( $style ) || ! is_array( $style ) ) { - return $parsed_block; + return null; } $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $parsed_block['blockName'] ); if ( ! block_has_support( $block_type, 'customCSS', true ) ) { - return $parsed_block; + return null; } $viewport_settings = wp_get_global_settings( array( 'viewport' ) ); $responsive_media_queries = WP_Theme_JSON::get_viewport_media_queries( $viewport_settings ); $state_entries = wp_get_custom_css_state_entries( $style, $parsed_block['blockName'], $responsive_media_queries ); if ( empty( $state_entries ) ) { - return $parsed_block; + return null; } // Generate a unique class name for this block instance. $class_name = wp_unique_id_from_values( $parsed_block, 'wp-custom-css-' ); $processed_css = wp_process_custom_css_state_entries( $state_entries, '.' . $class_name ); if ( '' === $processed_css ) { - return $parsed_block; + return null; } - $existing_class_name = $parsed_block['attrs']['className'] ?? null; - $updated_class_name = is_string( $existing_class_name ) - ? "$existing_class_name $class_name" - : $class_name; - - $parsed_block['attrs']['className'] = $updated_class_name; - /** * Reuse one handle so identical custom CSS is enqueued only once via * {@see wp_unique_id_from_values()}. Explicitly declare the `wp-block-library` @@ -206,6 +188,55 @@ function wp_render_custom_css_support_styles( $parsed_block ) { wp_add_inline_style( $handle, $processed_css ); } + return $class_name; +} + +/** + * Render the custom CSS stylesheet and add class name to block as required. + * + * @since 7.0.0 + * + * @param array $parsed_block The parsed block. + * @return array The same parsed block with custom CSS class name added if appropriate. + * + * @phpstan-param array{ + * blockName: string|null, + * attrs: array{ + * className?: string, + * style?: array{ + * css?: string, + * ... + * }, + * ... + * }, + * ... + * } $parsed_block + * @phpstan-return array{ + * blockName: string|null, + * attrs: array{ + * className?: string, + * style?: array{ + * css?: string, + * ... + * }, + * ... + * }, + * ... + * } + */ +function wp_render_custom_css_support_styles( $parsed_block ) { + $class_name = wp_generate_custom_css_class_name( $parsed_block ); + if ( null === $class_name ) { + return $parsed_block; + } + + $existing_class_name = $parsed_block['attrs']['className'] ?? null; + $updated_class_name = is_string( $existing_class_name ) + ? "$existing_class_name $class_name" + : $class_name; + + $parsed_block['attrs']['className'] = $updated_class_name; + return $parsed_block; } @@ -221,8 +252,10 @@ function wp_enqueue_block_custom_css() { /** * Applies the custom CSS class name to the block's rendered HTML. * - * The class name is generated in {@see wp_render_custom_css_support_styles()} - * and stored in block attributes. This filter adds it to the actual markup. + * Uses the class name that {@see wp_render_custom_css_support_styles()} + * stored in the block attributes. Blocks that a parent parsed and rendered + * itself never reach that filter, so the class name is generated here instead + * via {@see wp_generate_custom_css_class_name()}. * * @since 7.0.0 * @@ -231,31 +264,41 @@ function wp_enqueue_block_custom_css() { * @return string Filtered block content. * * @phpstan-param array{ + * blockName: string|null, * attrs: array{ * className?: string, + * style?: array{ + * css?: string, + * ... + * }, * ... * }, * ... * } $block */ function wp_render_custom_css_class_name( $block_content, $block ) { - $class_name_attr = $block['attrs']['className'] ?? null; - $class_name_prefix = 'wp-custom-css-'; - if ( ! is_string( $class_name_attr ) || ! str_contains( $class_name_attr, $class_name_prefix ) ) { - return $block_content; - } + $class_name_attr = $block['attrs']['className'] ?? null; + $class_name_prefix = 'wp-custom-css-'; + $matched_class_name = null; // Parse out the 'wp-custom-css-*' class name added by wp_render_custom_css_support_styles(). - $matched_class_name = null; - $token_delimiter = " \t\f\r\n"; - $class_token = strtok( $class_name_attr, $token_delimiter ); - while ( false !== $class_token ) { - if ( str_starts_with( $class_token, $class_name_prefix ) ) { - $matched_class_name = $class_token; - break; + if ( is_string( $class_name_attr ) && str_contains( $class_name_attr, $class_name_prefix ) ) { + $token_delimiter = " \t\f\r\n"; + $class_token = strtok( $class_name_attr, $token_delimiter ); + while ( false !== $class_token ) { + if ( str_starts_with( $class_token, $class_name_prefix ) ) { + $matched_class_name = $class_token; + break; + } + $class_token = strtok( $token_delimiter ); } - $class_token = strtok( $token_delimiter ); } + + // Direct WP_Block::render() calls skip render_block_data for the block itself. + if ( null === $matched_class_name ) { + $matched_class_name = wp_generate_custom_css_class_name( $block ); + } + if ( null === $matched_class_name ) { return $block_content; } diff --git a/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php b/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php index 21f3dabb456ca..523cf5f6e12cd 100644 --- a/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php +++ b/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php @@ -6,6 +6,30 @@ * @covers ::wp_render_custom_css_class_name */ class Tests_Block_Supports_WpRenderCustomCssClassName extends WP_UnitTestCase { + /** + * @var string|null + */ + private $test_block_name; + + public function set_up() { + parent::set_up(); + $this->test_block_name = null; + + global $wp_styles; + $wp_styles = null; + } + + public function tear_down() { + if ( $this->test_block_name ) { + unregister_block_type( $this->test_block_name ); + } + $this->test_block_name = null; + + global $wp_styles; + $wp_styles = null; + + parent::tear_down(); + } /** * Tests that the custom CSS class name is applied to block content. @@ -159,4 +183,155 @@ public static function data_returns_unchanged_content() { ), ); } + + /** + * Returns the custom CSS class name found in a string of rendered markup. + * + * @param string $markup Rendered block markup. + * @return string|null The custom CSS class name, or null if there is none. + */ + private function get_custom_css_class_name_from_markup( $markup ) { + return preg_match( '/\bwp-custom-css-[0-9a-f]+\b/', $markup, $matches ) ? $matches[0] : null; + } + + /** + * Returns the custom CSS rules currently attached to the custom CSS stylesheet. + * + * @return string The inline custom CSS. + */ + private function get_enqueued_custom_css() { + $after = wp_styles()->get_data( 'wp-block-custom-css', 'after' ); + + return is_array( $after ) ? implode( "\n", $after ) : ''; + } + + /** + * Tests that the class name and stylesheet are still generated for blocks that + * are rendered without the `render_block_data` filter running. + * + * Direct `WP_Block::render()` calls skip `render_block_data` for the block + * itself, as when `core/navigation` renders a `wp_navigation` post's blocks. + * + * @ticket 66345 + * + * @covers ::wp_render_custom_css_class_name + */ + public function test_generates_class_when_render_block_data_skipped() { + $this->test_block_name = 'test/custom-css-skipped-block-data'; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( 'customCSS' => true ), + ) + ); + + $block = array( + 'blockName' => 'test/custom-css-skipped-block-data', + 'attrs' => array( + 'style' => array( + 'css' => 'padding: 0;', + ), + ), + ); + + $result = wp_render_custom_css_class_name( '
  • Home
  • ', $block ); + $class_name = $this->get_custom_css_class_name_from_markup( $result ); + + $this->assertNotNull( $class_name, 'A custom CSS class should be added even though render_block_data did not run.' ); + $this->assertStringContainsString( 'has-custom-css', $result, 'The has-custom-css class should be added.' ); + $this->assertStringContainsString( 'wp-block-navigation-item', $result, 'Existing classes should be preserved.' ); + $this->assertStringContainsString( + '.' . $class_name, + $this->get_enqueued_custom_css(), + 'The custom CSS stylesheet should contain a rule for the generated class.' + ); + } + + /** + * Tests that a block is not given a second class name when `render_block_data` + * has already generated one. + * + * @ticket 66345 + * + * @covers ::wp_render_custom_css_class_name + */ + public function test_does_not_regenerate_existing_class() { + $this->test_block_name = 'test/custom-css-existing-class'; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( 'customCSS' => true ), + ) + ); + + $parsed_block = array( + 'blockName' => 'test/custom-css-existing-class', + 'attrs' => array( + 'style' => array( + 'css' => 'padding: 0;', + ), + ), + ); + + $filtered = wp_render_custom_css_support_styles( $parsed_block ); + $result = wp_render_custom_css_class_name( '

    Test content

    ', $filtered ); + + $this->assertSame( + 1, + preg_match_all( '/wp-custom-css-[0-9a-f]+/', $result ), + 'Only the class name generated by render_block_data should be added.' + ); + $this->assertStringContainsString( + $filtered['attrs']['className'], + $result, + 'The class name generated by render_block_data should be the one added to the markup.' + ); + } + + /** + * Tests that blocks inside a navigation menu get their custom CSS on the front end. + * + * @ticket 66345 + * + * @covers ::wp_render_custom_css_class_name + */ + public function test_custom_css_is_rendered_for_blocks_inside_a_navigation_menu() { + /* + * Custom CSS is stripped on save for users without the `edit_css` capability, + * which the default test user does not have. Hooks are restored on tear down. + */ + remove_filter( 'content_save_pre', 'wp_strip_custom_css_from_blocks', 8 ); + + $navigation_id = self::factory()->post->create( + array( + 'post_type' => 'wp_navigation', + 'post_title' => 'Custom CSS Menu', + 'post_status' => 'publish', + 'post_content' => '', + ) + ); + + $rendered = do_blocks( '' ); + + $class_name = $this->get_custom_css_class_name_from_markup( $rendered ); + + $this->assertNotNull( $class_name, 'The navigation link should be given a custom CSS class.' ); + $this->assertStringContainsString( + '.' . $class_name, + $this->get_enqueued_custom_css(), + 'The custom CSS stylesheet should contain a rule for the navigation link.' + ); + } } From 12a1a096cefdad3439ac3d6fbaf5ae180a138ba0 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 20 Oct 2026 17:55:12 +1100 Subject: [PATCH 2/3] Docs: Add a 7.2.0 changelog entry to `wp_render_custom_css_class_name()`. See #66345. --- src/wp-includes/block-supports/custom-css.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/block-supports/custom-css.php b/src/wp-includes/block-supports/custom-css.php index 2871a3c38a768..1c9a6a95d048d 100644 --- a/src/wp-includes/block-supports/custom-css.php +++ b/src/wp-includes/block-supports/custom-css.php @@ -258,6 +258,7 @@ function wp_enqueue_block_custom_css() { * via {@see wp_generate_custom_css_class_name()}. * * @since 7.0.0 + * @since 7.2.0 Generates the class name when the block attributes lack one. * * @param string $block_content Rendered block content. * @param array $block Block object. From c99a4258ee9331c7be3563fa27380d0057b10a3c Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 21 Oct 2026 19:31:37 +1100 Subject: [PATCH 3/3] Tests: Reset script modules in `wp_render_custom_css_class_name()` tests. The Navigation menu test renders `core/navigation`, which enqueues the Navigation view script module. The queue was not reset between tests, so the module leaked into later test classes and broke `Tests_Blocks_Register::test_register_block_script_module_id_with_interactivity_true` when the full suite ran. See #66345. --- .../block-supports/wpRenderCustomCssClassName.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php b/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php index 523cf5f6e12cd..1adf0642680c6 100644 --- a/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php +++ b/tests/phpunit/tests/block-supports/wpRenderCustomCssClassName.php @@ -15,8 +15,9 @@ public function set_up() { parent::set_up(); $this->test_block_name = null; - global $wp_styles; - $wp_styles = null; + global $wp_styles, $wp_script_modules; + $wp_styles = null; + $wp_script_modules = null; } public function tear_down() { @@ -25,8 +26,9 @@ public function tear_down() { } $this->test_block_name = null; - global $wp_styles; - $wp_styles = null; + global $wp_styles, $wp_script_modules; + $wp_styles = null; + $wp_script_modules = null; parent::tear_down(); }