perf: optimize CSS selector pruning (#17846)

## Summary

Reduces CSS selector pruning overhead by eliminating unnecessary
allocations and redundant work in the hot path.

**Changes:**
- Replace `.slice()` + `.shift()`/`.pop()` in `apply_selector` with
index-based `from`/`to` params — avoids O(n) array copies per recursive
call
- Merge `has_selectors`/`other_selectors` split in
`relative_selector_might_apply_to_node` into a single pass — eliminates
2 temporary array allocations per call
- Hoist `name.toLowerCase()` out of the inner loop in
`attribute_matches`
- Replace `value.split(/\s/).includes()` with `indexOf` + boundary
checks in `test_attribute` for `~=` — avoids array allocation on every
class match
- Skip `name.replace()` regex when selector name has no backslash

## Benchmark

Interleaved benchmark (5 rounds, alternating baseline/optimized):

```
--- Round 1 ---
  Baseline:  min=59.58ms  median=64.63ms
  Optimized: min=47.20ms  median=54.35ms
--- Round 2 ---
  Baseline:  min=59.11ms  median=64.90ms
  Optimized: min=48.36ms  median=54.74ms
--- Round 3 ---
  Baseline:  min=58.38ms  median=64.06ms
  Optimized: min=48.38ms  median=53.83ms
--- Round 4 ---
  Baseline:  min=58.49ms  median=63.99ms
  Optimized: min=48.45ms  median=53.82ms
--- Round 5 ---
  Baseline:  min=58.40ms  median=64.07ms
  Optimized: min=48.97ms  median=54.64ms

Best min:    Before=58.38ms  After=47.20ms  Improvement=19.1%
Best median: Before=63.99ms  After=53.82ms  Improvement=15.9%
```

CPU profile before → after:
| Function | Before | After |
|---|---|---|
| `relative_selector_might_apply_to_node` | 14.3% | 5.2% |
| `attribute_matches` | 4.0% | 3.3% |
| `test_attribute` | 3.2% | <0.9% |

## Test plan

- [x] All 196 CSS tests pass (180 samples + 16 parse)
- [x] All 31 snapshot tests pass
- [x] All 2380 runtime-runes tests pass
- [x] All 3291 runtime-legacy tests pass
- [x] All 145 compiler-errors tests pass
- [x] All 326 validator tests pass
- [x] Added `css-prune-edge-cases` test covering: `~=` word matching
(substring vs whole word), deep combinator chains (4+ levels), `:has()`
combined with class selectors, escaped selectors,
`:is()`/`:where()`/`:not()` with combinators
- [x] Edge case test passes on both baseline and optimized code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/17842/head
Mathias Picker 1 week ago committed by GitHub
parent 32111f9e84
commit 0965028d3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
perf: optimize CSS selector pruning

@ -236,16 +236,36 @@ function truncate(node) {
* @param {Compiler.AST.CSS.Rule} rule
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
* @param {Direction} direction
* @param {number} [from]
* @param {number} [to]
* @returns {boolean}
*/
function apply_selector(relative_selectors, rule, element, direction) {
const rest_selectors = relative_selectors.slice();
const relative_selector = direction === FORWARD ? rest_selectors.shift() : rest_selectors.pop();
function apply_selector(
relative_selectors,
rule,
element,
direction,
from = 0,
to = relative_selectors.length
) {
if (from >= to) return false;
const selector_index = direction === FORWARD ? from : to - 1;
const relative_selector = relative_selectors[selector_index];
const rest_from = direction === FORWARD ? from + 1 : from;
const rest_to = direction === FORWARD ? to : to - 1;
const matched =
!!relative_selector &&
relative_selector_might_apply_to_node(relative_selector, rule, element, direction) &&
apply_combinator(relative_selector, rest_selectors, rule, element, direction);
apply_combinator(
relative_selector,
relative_selectors,
rest_from,
rest_to,
rule,
element,
direction
);
if (matched) {
if (!is_outer_global(relative_selector)) {
@ -260,15 +280,21 @@ function apply_selector(relative_selectors, rule, element, direction) {
/**
* @param {Compiler.AST.CSS.RelativeSelector} relative_selector
* @param {Compiler.AST.CSS.RelativeSelector[]} rest_selectors
* @param {Compiler.AST.CSS.RelativeSelector[]} relative_selectors
* @param {number} from
* @param {number} to
* @param {Compiler.AST.CSS.Rule} rule
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement | Compiler.AST.RenderTag | Compiler.AST.Component | Compiler.AST.SvelteComponent | Compiler.AST.SvelteSelf} node
* @param {Direction} direction
* @returns {boolean}
*/
function apply_combinator(relative_selector, rest_selectors, rule, node, direction) {
function apply_combinator(relative_selector, relative_selectors, from, to, rule, node, direction) {
const combinator =
direction == FORWARD ? rest_selectors[0]?.combinator : relative_selector.combinator;
direction == FORWARD
? from < to
? relative_selectors[from].combinator
: undefined
: relative_selector.combinator;
if (!combinator) return true;
switch (combinator.name) {
@ -282,7 +308,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
let parent_matched = false;
for (const parent of parents) {
if (apply_selector(rest_selectors, rule, parent, direction)) {
if (apply_selector(relative_selectors, rule, parent, direction, from, to)) {
parent_matched = true;
}
}
@ -291,7 +317,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
parent_matched ||
(direction === BACKWARD &&
(!is_adjacent || parents.length === 0) &&
rest_selectors.every((selector) => is_global(selector, rule)))
every_is_global(relative_selectors, from, to, rule))
);
}
@ -308,10 +334,12 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
possible_sibling.type === 'Component'
) {
// `{@render foo()}<p>foo</p>` with `:global(.x) + p` is a match
if (rest_selectors.length === 1 && rest_selectors[0].metadata.is_global) {
if (to - from === 1 && relative_selectors[from].metadata.is_global) {
sibling_matched = true;
}
} else if (apply_selector(rest_selectors, rule, possible_sibling, direction)) {
} else if (
apply_selector(relative_selectors, rule, possible_sibling, direction, from, to)
) {
sibling_matched = true;
}
}
@ -320,7 +348,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
sibling_matched ||
(direction === BACKWARD &&
get_element_parent(node) === null &&
rest_selectors.every((selector) => is_global(selector, rule)))
every_is_global(relative_selectors, from, to, rule))
);
}
@ -330,6 +358,20 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
}
}
/**
* @param {Compiler.AST.CSS.RelativeSelector[]} relative_selectors
* @param {number} from
* @param {number} to
* @param {Compiler.AST.CSS.Rule} rule
* @returns {boolean}
*/
function every_is_global(relative_selectors, from, to, rule) {
for (let i = from; i < to; i++) {
if (!is_global(relative_selectors[i], rule)) return false;
}
return true;
}
/**
* Returns `true` if the relative selector is global, meaning
* it's a `:global(...)` or unscopeable selector, or
@ -392,42 +434,37 @@ const regex_backslash_and_following_character = /\\(.)/g;
* @returns {boolean}
*/
function relative_selector_might_apply_to_node(relative_selector, rule, element, direction) {
// Sort :has(...) selectors in one bucket and everything else into another
const has_selectors = [];
const other_selectors = [];
/** @type {boolean | undefined} */
let include_self;
for (const selector of relative_selector.selectors) {
// Handle :has(...) selectors inline to avoid allocating temporary arrays
if (selector.type === 'PseudoClassSelector' && selector.name === 'has' && selector.args) {
has_selectors.push(selector);
} else {
other_selectors.push(selector);
}
}
// If we're called recursively from a :has(...) selector, we're on the way of checking if the other selectors match.
// In that case ignore this check (because we just came from this) to avoid an infinite loop.
if (has_selectors.length > 0) {
// If this is a :has inside a global selector, we gotta include the element itself, too,
// because the global selector might be for an element that's outside the component,
// e.g. :root:has(.scoped), :global(.foo):has(.scoped), or :root { &:has(.scoped) {} }
const rules = get_parent_rules(rule);
const include_self =
rules.some((r) => r.prelude.children.some((c) => c.children.some((s) => is_global(s, r)))) ||
rules[rules.length - 1].prelude.children.some((c) =>
c.children.some((r) =>
r.selectors.some(
(s) =>
s.type === 'PseudoClassSelector' &&
(s.name === 'root' || (s.name === 'global' && s.args))
)
)
);
// Lazy-compute include_self on first :has encounter
if (include_self === undefined) {
// If this is a :has inside a global selector, we gotta include the element itself, too,
// because the global selector might be for an element that's outside the component,
// e.g. :root:has(.scoped), :global(.foo):has(.scoped), or :root { &:has(.scoped) {} }
const rules = get_parent_rules(rule);
include_self =
rules.some((r) =>
r.prelude.children.some((c) => c.children.some((s) => is_global(s, r)))
) ||
rules[rules.length - 1].prelude.children.some((c) =>
c.children.some((r) =>
r.selectors.some(
(s) =>
s.type === 'PseudoClassSelector' &&
(s.name === 'root' || (s.name === 'global' && s.args))
)
)
);
}
// :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
// upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
// selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
for (const has_selector of has_selectors) {
const complex_selectors = /** @type {Compiler.AST.CSS.SelectorList} */ (has_selector.args)
// :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
// upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
// selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
const complex_selectors = /** @type {Compiler.AST.CSS.SelectorList} */ (selector.args)
.children;
let matched = false;
@ -465,13 +502,15 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
if (!matched) {
return false;
}
continue;
}
}
for (const selector of other_selectors) {
if (selector.type === 'Percentage' || selector.type === 'Nth') continue;
const name = selector.name.replace(regex_backslash_and_following_character, '$1');
const name = selector.name.includes('\\')
? selector.name.replace(regex_backslash_and_following_character, '$1')
: selector.name;
switch (selector.type) {
case 'PseudoClassSelector': {
@ -672,11 +711,11 @@ function test_attribute(operator, expected_value, case_insensitive, value) {
* @param {boolean} case_insensitive
*/
function attribute_matches(node, name, expected_value, operator, case_insensitive) {
const name_lower = name.toLowerCase();
for (const attribute of node.attributes) {
if (attribute.type === 'SpreadAttribute') return true;
if (attribute.type === 'BindDirective' && attribute.name === name) return true;
const name_lower = name.toLowerCase();
// match attributes against the corresponding directive but bail out on exact matching
if (attribute.type === 'StyleDirective' && name_lower === 'style') return true;
if (attribute.type === 'ClassDirective' && name_lower === 'class') {

@ -0,0 +1,48 @@
import { test } from '../../test';
export default test({
warnings: [
{
code: 'css_unused_selector',
message: 'Unused CSS selector ".foob"',
start: {
line: 64,
column: 1,
character: 1574
},
end: {
line: 64,
column: 6,
character: 1579
}
},
{
code: 'css_unused_selector',
message: 'Unused CSS selector "main > article > div > section > span"',
start: {
line: 84,
column: 1,
character: 2196
},
end: {
line: 84,
column: 38,
character: 2233
}
},
{
code: 'css_unused_selector',
message: 'Unused CSS selector "nav:has(button).primary"',
start: {
line: 95,
column: 1,
character: 2560
},
end: {
line: 95,
column: 24,
character: 2583
}
}
]
});

@ -0,0 +1,80 @@
/* === ~= word matching === */
/* Should match: "foo" is a whole word in class="foo bar" */
.foo.svelte-xyz { color: green; }
/* Should match: "bar" is a whole word in class="foo bar" */
.bar.svelte-xyz { color: green; }
/* Should match: "foobar" is the whole class value */
.foobar.svelte-xyz { color: green; }
/* Should match: "bar-foo" is a whole word (hyphen not whitespace) */
.bar-foo.svelte-xyz { color: green; }
/* Should match: "baz" is a whole word in class="bar-foo baz" */
.baz.svelte-xyz { color: green; }
/* Should NOT match: "foob" is not a word in any element's class */
/* (unused) .foob { color: red; }*/
/* Should NOT match: "afoo" is a word but "foo-x" is not "foo" */
[class~="foo-x"].svelte-xyz { color: green; }
/* Attribute selector with ~= operator directly */
[class~="afoo"].svelte-xyz { color: green; }
/* === Deep combinator chains (4+ levels) === */
/* Should match: exact chain main > article > section > div > span */
main.svelte-xyz > article:where(.svelte-xyz) > section:where(.svelte-xyz) > div:where(.svelte-xyz) > span:where(.svelte-xyz) { color: green; }
/* Should match: descendant chain */
main.svelte-xyz article:where(.svelte-xyz) section:where(.svelte-xyz) div:where(.svelte-xyz) span:where(.svelte-xyz) { color: green; }
/* Should match: mixed combinators */
main.svelte-xyz > article:where(.svelte-xyz) section:where(.svelte-xyz) > div:where(.svelte-xyz) span:where(.svelte-xyz) { color: green; }
/* Should NOT match: wrong nesting order */
/* (unused) main > article > div > section > span { color: red; }*/
/* === :has() combined with other selectors === */
/* Should match: nav.primary has <a> descendant */
nav:has(a:where(.svelte-xyz)).primary.svelte-xyz { color: green; }
/* Should match: nav.secondary has <button> descendant */
nav:has(button:where(.svelte-xyz)).secondary.svelte-xyz { color: green; }
/* Should NOT match: nav.primary doesn't have <button> */
/* (unused) nav:has(button).primary { color: red; }*/
/* Multiple :has() on same element */
main.svelte-xyz:has(article:where(.svelte-xyz)):has(span:where(.svelte-xyz)) { color: green; }
/* :has() with child combinator */
main.svelte-xyz:has(> article:where(.svelte-xyz)) { color: green; }
/* === Escaped selectors === */
.a\-b.svelte-xyz { color: green; }
/* === :is()/:where()/:not() with deep selectors === */
/* :is() with matching selector */
header.svelte-xyz :is(h1:where(.svelte-xyz)) { color: green; }
/* :where() with matching selector */
ul.svelte-xyz :where(li:where(.svelte-xyz)) { color: green; }
/* :not() — should match span since it's not a div */
span.svelte-xyz:not(div) { color: green; }
/* :is() with deep combinator */
ul.svelte-xyz :is(li:where(.svelte-xyz) > span:where(.svelte-xyz)) { color: green; }
/* :not() with class — p.a-b is :not(.unused) */
p.svelte-xyz:not(.unused) { color: green; }
/* Complex: :has() + :is() */
ul.svelte-xyz:has(li:where(.svelte-xyz)) :is(span:where(.svelte-xyz)) { color: green; }

@ -0,0 +1,125 @@
<!-- Edge cases for CSS pruning optimizations:
1. ~= word matching (indexOf vs split)
2. Deep combinator chains (index-based apply_selector)
3. :has() combined with other selectors (single-pass handling)
4. Escaped selectors (backslash skip optimization)
5. :is()/:where()/:not() with deep selectors
-->
<!-- ~= word matching edge cases -->
<div class="foo bar">word match</div>
<div class="foobar">substring only</div>
<div class="bar-foo baz">hyphen separated</div>
<div class="afoo foo-x">prefix substring</div>
<!-- Deep combinator chains -->
<main>
<article>
<section>
<div>
<span class="deep">deep</span>
</div>
</section>
</article>
</main>
<!-- :has() with class selectors -->
<nav class="primary">
<a href="/">link</a>
</nav>
<nav class="secondary">
<button>action</button>
</nav>
<!-- Escaped selectors -->
<p class="a-b">escaped</p>
<!-- :is()/:where()/:not() with combinators -->
<header>
<h1>title</h1>
</header>
<ul>
<li class="active"><span>item</span></li>
</ul>
<style>
/* === ~= word matching === */
/* Should match: "foo" is a whole word in class="foo bar" */
.foo { color: green; }
/* Should match: "bar" is a whole word in class="foo bar" */
.bar { color: green; }
/* Should match: "foobar" is the whole class value */
.foobar { color: green; }
/* Should match: "bar-foo" is a whole word (hyphen not whitespace) */
.bar-foo { color: green; }
/* Should match: "baz" is a whole word in class="bar-foo baz" */
.baz { color: green; }
/* Should NOT match: "foob" is not a word in any element's class */
.foob { color: red; }
/* Should NOT match: "afoo" is a word but "foo-x" is not "foo" */
[class~="foo-x"] { color: green; }
/* Attribute selector with ~= operator directly */
[class~="afoo"] { color: green; }
/* === Deep combinator chains (4+ levels) === */
/* Should match: exact chain main > article > section > div > span */
main > article > section > div > span { color: green; }
/* Should match: descendant chain */
main article section div span { color: green; }
/* Should match: mixed combinators */
main > article section > div span { color: green; }
/* Should NOT match: wrong nesting order */
main > article > div > section > span { color: red; }
/* === :has() combined with other selectors === */
/* Should match: nav.primary has <a> descendant */
nav:has(a).primary { color: green; }
/* Should match: nav.secondary has <button> descendant */
nav:has(button).secondary { color: green; }
/* Should NOT match: nav.primary doesn't have <button> */
nav:has(button).primary { color: red; }
/* Multiple :has() on same element */
main:has(article):has(span) { color: green; }
/* :has() with child combinator */
main:has(> article) { color: green; }
/* === Escaped selectors === */
.a\-b { color: green; }
/* === :is()/:where()/:not() with deep selectors === */
/* :is() with matching selector */
header :is(h1) { color: green; }
/* :where() with matching selector */
ul :where(li) { color: green; }
/* :not() — should match span since it's not a div */
span:not(div) { color: green; }
/* :is() with deep combinator */
ul :is(li > span) { color: green; }
/* :not() with class — p.a-b is :not(.unused) */
p:not(.unused) { color: green; }
/* Complex: :has() + :is() */
ul:has(li) :is(span) { color: green; }
</style>
Loading…
Cancel
Save