From 2526bd1505054b08d383f223d9f0ecb5ee1e64e7 Mon Sep 17 00:00:00 2001 From: Joseph Scott Date: Wed, 24 Jun 2026 10:05:42 -0600 Subject: [PATCH] Editor: prepend_to_selector: optimized with str_replace() Trac ticket: https://core.trac.wordpress.org/ticket/65533 This PR syncs the Gutenberg PR: https://github.com/WordPress/gutenberg/pull/76556 --- src/wp-includes/class-wp-theme-json.php | 4 + tests/phpunit/tests/theme/wpThemeJson.php | 140 ++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index d464e19ec879c..6cc42cc7efb28 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -1251,6 +1251,10 @@ protected static function prepend_to_selector( $selector, $to_prepend ) { if ( ! str_contains( $selector, ',' ) ) { return $to_prepend . $selector; } + // Fast path: no parentheses means all commas are top-level separators. + if ( ! str_contains( $selector, '(' ) ) { + return $to_prepend . str_replace( ',', ',' . $to_prepend, $selector ); + } $new_selectors = array(); $selectors = explode( ',', $selector ); foreach ( $selectors as $sel ) { diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 7eda511e1d9ec..c340ce4edf9fb 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -7532,4 +7532,144 @@ public function test_to_ruleset_skips_non_scalar_values_and_casts_numerics() { $this->assertStringNotContainsString( 'padding', $result, 'Boolean value should be skipped' ); $this->assertStringNotContainsString( 'gap', $result, 'Array value should be skipped' ); } + + /** + * Tests that prepend_to_selector correctly prepends to single and + * compound (comma-separated) selectors. + * + * @ticket 65533 + * + * @dataProvider data_prepend_to_selector + * + * @param string $selector Original CSS selector. + * @param string $to_prepend Selector to prepend. + * @param string $expected Expected resulting selector. + */ + public function test_prepend_to_selector( $selector, $to_prepend, $expected ) { + $theme_json = new ReflectionClass( 'WP_Theme_JSON' ); + + $func = $theme_json->getMethod( 'prepend_to_selector' ); + if ( PHP_VERSION_ID < 80100 ) { + $func->setAccessible( true ); + } + + $actual = $func->invoke( null, $selector, $to_prepend ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Data provider for prepend_to_selector tests. + * + * @return array[] + */ + public function data_prepend_to_selector() { + return array( + 'single class selector' => array( + 'selector' => '.inner', + 'to_prepend' => '.wrapper ', + 'expected' => '.wrapper .inner', + ), + 'single element selector' => array( + 'selector' => 'p', + 'to_prepend' => '.wrapper ', + 'expected' => '.wrapper p', + ), + 'single id selector' => array( + 'selector' => '#main', + 'to_prepend' => '.wrapper ', + 'expected' => '.wrapper #main', + ), + 'two comma-separated selectors without spaces' => array( + 'selector' => 'h1,h2', + 'to_prepend' => '.wrapper ', + 'expected' => '.wrapper h1,.wrapper h2', + ), + 'three comma-separated selectors without spaces' => array( + 'selector' => 'h1,h2,h3', + 'to_prepend' => '.some-class ', + 'expected' => '.some-class h1,.some-class h2,.some-class h3', + ), + 'comma-separated class selectors without spaces' => array( + 'selector' => '.foo,.bar', + 'to_prepend' => '.prefix ', + 'expected' => '.prefix .foo,.prefix .bar', + ), + 'prepend without trailing space' => array( + 'selector' => '.child', + 'to_prepend' => '.parent', + 'expected' => '.parent.child', + ), + 'compound selector without trailing space no comma spaces' => array( + 'selector' => '.a,.b', + 'to_prepend' => '.parent', + 'expected' => '.parent.a,.parent.b', + ), + 'descendant selector prepended' => array( + 'selector' => '.block .inner', + 'to_prepend' => '.scope ', + 'expected' => '.scope .block .inner', + ), + 'descendant selectors comma-separated without spaces' => array( + 'selector' => '.block .inner,.block .alt', + 'to_prepend' => '.scope ', + 'expected' => '.scope .block .inner,.scope .block .alt', + ), + 'empty selector' => array( + 'selector' => '', + 'to_prepend' => '.prefix ', + 'expected' => '.prefix ', + ), + 'empty prepend' => array( + 'selector' => '.child', + 'to_prepend' => '', + 'expected' => '.child', + ), + 'both empty' => array( + 'selector' => '', + 'to_prepend' => '', + 'expected' => '', + ), + 'attribute selector' => array( + 'selector' => '[data-type="example"]', + 'to_prepend' => '.scope ', + 'expected' => '.scope [data-type="example"]', + ), + 'pseudo-class selector' => array( + 'selector' => ':where(.is-layout-flex)', + 'to_prepend' => '.editor ', + 'expected' => '.editor :where(.is-layout-flex)', + ), + 'many comma-separated selectors without spaces' => array( + 'selector' => 'h1,h2,h3,h4,h5,h6', + 'to_prepend' => '.content ', + 'expected' => '.content h1,.content h2,.content h3,.content h4,.content h5,.content h6', + ), + 'real world block element selector' => array( + 'selector' => 'p', + 'to_prepend' => '.wp-block-group ', + 'expected' => '.wp-block-group p', + ), + 'real world compound block element selectors' => array( + 'selector' => 'a,.wp-element-button', + 'to_prepend' => '.wp-block-group ', + 'expected' => '.wp-block-group a,.wp-block-group .wp-element-button', + ), + 'spaces after commas are preserved' => array( + 'selector' => 'h1, h2, h3', + 'to_prepend' => '.some-class ', + 'expected' => '.some-class h1,.some-class h2,.some-class h3', + ), + 'spaces after commas preserved with class selectors' => array( + 'selector' => '.foo, .bar', + 'to_prepend' => '.prefix ', + 'expected' => '.prefix .foo,.prefix .bar', + ), + 'mixed whitespace around commas preserved' => array( + 'selector' => '.a , .b , .c', + 'to_prepend' => '.pre ', + 'expected' => '.pre .a ,.pre .b ,.pre .c', + ), + ); + } }