Skip to content

Commit 0965028

Browse files
MathiasWPclaudeRich-Harris
authored
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>
1 parent 32111f9 commit 0965028

5 files changed

Lines changed: 347 additions & 50 deletions

File tree

.changeset/fast-css-prune.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
perf: optimize CSS selector pruning

packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js

Lines changed: 89 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -236,16 +236,36 @@ function truncate(node) {
236236
* @param {Compiler.AST.CSS.Rule} rule
237237
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
238238
* @param {Direction} direction
239+
* @param {number} [from]
240+
* @param {number} [to]
239241
* @returns {boolean}
240242
*/
241-
function apply_selector(relative_selectors, rule, element, direction) {
242-
const rest_selectors = relative_selectors.slice();
243-
const relative_selector = direction === FORWARD ? rest_selectors.shift() : rest_selectors.pop();
243+
function apply_selector(
244+
relative_selectors,
245+
rule,
246+
element,
247+
direction,
248+
from = 0,
249+
to = relative_selectors.length
250+
) {
251+
if (from >= to) return false;
252+
253+
const selector_index = direction === FORWARD ? from : to - 1;
254+
const relative_selector = relative_selectors[selector_index];
255+
const rest_from = direction === FORWARD ? from + 1 : from;
256+
const rest_to = direction === FORWARD ? to : to - 1;
244257

245258
const matched =
246-
!!relative_selector &&
247259
relative_selector_might_apply_to_node(relative_selector, rule, element, direction) &&
248-
apply_combinator(relative_selector, rest_selectors, rule, element, direction);
260+
apply_combinator(
261+
relative_selector,
262+
relative_selectors,
263+
rest_from,
264+
rest_to,
265+
rule,
266+
element,
267+
direction
268+
);
249269

250270
if (matched) {
251271
if (!is_outer_global(relative_selector)) {
@@ -260,15 +280,21 @@ function apply_selector(relative_selectors, rule, element, direction) {
260280

261281
/**
262282
* @param {Compiler.AST.CSS.RelativeSelector} relative_selector
263-
* @param {Compiler.AST.CSS.RelativeSelector[]} rest_selectors
283+
* @param {Compiler.AST.CSS.RelativeSelector[]} relative_selectors
284+
* @param {number} from
285+
* @param {number} to
264286
* @param {Compiler.AST.CSS.Rule} rule
265287
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement | Compiler.AST.RenderTag | Compiler.AST.Component | Compiler.AST.SvelteComponent | Compiler.AST.SvelteSelf} node
266288
* @param {Direction} direction
267289
* @returns {boolean}
268290
*/
269-
function apply_combinator(relative_selector, rest_selectors, rule, node, direction) {
291+
function apply_combinator(relative_selector, relative_selectors, from, to, rule, node, direction) {
270292
const combinator =
271-
direction == FORWARD ? rest_selectors[0]?.combinator : relative_selector.combinator;
293+
direction == FORWARD
294+
? from < to
295+
? relative_selectors[from].combinator
296+
: undefined
297+
: relative_selector.combinator;
272298
if (!combinator) return true;
273299

274300
switch (combinator.name) {
@@ -282,7 +308,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
282308
let parent_matched = false;
283309

284310
for (const parent of parents) {
285-
if (apply_selector(rest_selectors, rule, parent, direction)) {
311+
if (apply_selector(relative_selectors, rule, parent, direction, from, to)) {
286312
parent_matched = true;
287313
}
288314
}
@@ -291,7 +317,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
291317
parent_matched ||
292318
(direction === BACKWARD &&
293319
(!is_adjacent || parents.length === 0) &&
294-
rest_selectors.every((selector) => is_global(selector, rule)))
320+
every_is_global(relative_selectors, from, to, rule))
295321
);
296322
}
297323

@@ -308,10 +334,12 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
308334
possible_sibling.type === 'Component'
309335
) {
310336
// `{@render foo()}<p>foo</p>` with `:global(.x) + p` is a match
311-
if (rest_selectors.length === 1 && rest_selectors[0].metadata.is_global) {
337+
if (to - from === 1 && relative_selectors[from].metadata.is_global) {
312338
sibling_matched = true;
313339
}
314-
} else if (apply_selector(rest_selectors, rule, possible_sibling, direction)) {
340+
} else if (
341+
apply_selector(relative_selectors, rule, possible_sibling, direction, from, to)
342+
) {
315343
sibling_matched = true;
316344
}
317345
}
@@ -320,7 +348,7 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
320348
sibling_matched ||
321349
(direction === BACKWARD &&
322350
get_element_parent(node) === null &&
323-
rest_selectors.every((selector) => is_global(selector, rule)))
351+
every_is_global(relative_selectors, from, to, rule))
324352
);
325353
}
326354

@@ -330,6 +358,20 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
330358
}
331359
}
332360

361+
/**
362+
* @param {Compiler.AST.CSS.RelativeSelector[]} relative_selectors
363+
* @param {number} from
364+
* @param {number} to
365+
* @param {Compiler.AST.CSS.Rule} rule
366+
* @returns {boolean}
367+
*/
368+
function every_is_global(relative_selectors, from, to, rule) {
369+
for (let i = from; i < to; i++) {
370+
if (!is_global(relative_selectors[i], rule)) return false;
371+
}
372+
return true;
373+
}
374+
333375
/**
334376
* Returns `true` if the relative selector is global, meaning
335377
* it's a `:global(...)` or unscopeable selector, or
@@ -392,42 +434,37 @@ const regex_backslash_and_following_character = /\\(.)/g;
392434
* @returns {boolean}
393435
*/
394436
function relative_selector_might_apply_to_node(relative_selector, rule, element, direction) {
395-
// Sort :has(...) selectors in one bucket and everything else into another
396-
const has_selectors = [];
397-
const other_selectors = [];
437+
/** @type {boolean | undefined} */
438+
let include_self;
398439

399440
for (const selector of relative_selector.selectors) {
441+
// Handle :has(...) selectors inline to avoid allocating temporary arrays
400442
if (selector.type === 'PseudoClassSelector' && selector.name === 'has' && selector.args) {
401-
has_selectors.push(selector);
402-
} else {
403-
other_selectors.push(selector);
404-
}
405-
}
406-
407-
// If we're called recursively from a :has(...) selector, we're on the way of checking if the other selectors match.
408-
// In that case ignore this check (because we just came from this) to avoid an infinite loop.
409-
if (has_selectors.length > 0) {
410-
// If this is a :has inside a global selector, we gotta include the element itself, too,
411-
// because the global selector might be for an element that's outside the component,
412-
// e.g. :root:has(.scoped), :global(.foo):has(.scoped), or :root { &:has(.scoped) {} }
413-
const rules = get_parent_rules(rule);
414-
const include_self =
415-
rules.some((r) => r.prelude.children.some((c) => c.children.some((s) => is_global(s, r)))) ||
416-
rules[rules.length - 1].prelude.children.some((c) =>
417-
c.children.some((r) =>
418-
r.selectors.some(
419-
(s) =>
420-
s.type === 'PseudoClassSelector' &&
421-
(s.name === 'root' || (s.name === 'global' && s.args))
422-
)
423-
)
424-
);
443+
// Lazy-compute include_self on first :has encounter
444+
if (include_self === undefined) {
445+
// If this is a :has inside a global selector, we gotta include the element itself, too,
446+
// because the global selector might be for an element that's outside the component,
447+
// e.g. :root:has(.scoped), :global(.foo):has(.scoped), or :root { &:has(.scoped) {} }
448+
const rules = get_parent_rules(rule);
449+
include_self =
450+
rules.some((r) =>
451+
r.prelude.children.some((c) => c.children.some((s) => is_global(s, r)))
452+
) ||
453+
rules[rules.length - 1].prelude.children.some((c) =>
454+
c.children.some((r) =>
455+
r.selectors.some(
456+
(s) =>
457+
s.type === 'PseudoClassSelector' &&
458+
(s.name === 'root' || (s.name === 'global' && s.args))
459+
)
460+
)
461+
);
462+
}
425463

426-
// :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
427-
// upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
428-
// selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
429-
for (const has_selector of has_selectors) {
430-
const complex_selectors = /** @type {Compiler.AST.CSS.SelectorList} */ (has_selector.args)
464+
// :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
465+
// upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
466+
// selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
467+
const complex_selectors = /** @type {Compiler.AST.CSS.SelectorList} */ (selector.args)
431468
.children;
432469
let matched = false;
433470

@@ -465,13 +502,15 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
465502
if (!matched) {
466503
return false;
467504
}
505+
506+
continue;
468507
}
469-
}
470508

471-
for (const selector of other_selectors) {
472509
if (selector.type === 'Percentage' || selector.type === 'Nth') continue;
473510

474-
const name = selector.name.replace(regex_backslash_and_following_character, '$1');
511+
const name = selector.name.includes('\\')
512+
? selector.name.replace(regex_backslash_and_following_character, '$1')
513+
: selector.name;
475514

476515
switch (selector.type) {
477516
case 'PseudoClassSelector': {
@@ -672,11 +711,11 @@ function test_attribute(operator, expected_value, case_insensitive, value) {
672711
* @param {boolean} case_insensitive
673712
*/
674713
function attribute_matches(node, name, expected_value, operator, case_insensitive) {
714+
const name_lower = name.toLowerCase();
715+
675716
for (const attribute of node.attributes) {
676717
if (attribute.type === 'SpreadAttribute') return true;
677718
if (attribute.type === 'BindDirective' && attribute.name === name) return true;
678-
679-
const name_lower = name.toLowerCase();
680719
// match attributes against the corresponding directive but bail out on exact matching
681720
if (attribute.type === 'StyleDirective' && name_lower === 'style') return true;
682721
if (attribute.type === 'ClassDirective' && name_lower === 'class') {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
warnings: [
5+
{
6+
code: 'css_unused_selector',
7+
message: 'Unused CSS selector ".foob"',
8+
start: {
9+
line: 64,
10+
column: 1,
11+
character: 1574
12+
},
13+
end: {
14+
line: 64,
15+
column: 6,
16+
character: 1579
17+
}
18+
},
19+
{
20+
code: 'css_unused_selector',
21+
message: 'Unused CSS selector "main > article > div > section > span"',
22+
start: {
23+
line: 84,
24+
column: 1,
25+
character: 2196
26+
},
27+
end: {
28+
line: 84,
29+
column: 38,
30+
character: 2233
31+
}
32+
},
33+
{
34+
code: 'css_unused_selector',
35+
message: 'Unused CSS selector "nav:has(button).primary"',
36+
start: {
37+
line: 95,
38+
column: 1,
39+
character: 2560
40+
},
41+
end: {
42+
line: 95,
43+
column: 24,
44+
character: 2583
45+
}
46+
}
47+
]
48+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
2+
/* === ~= word matching === */
3+
4+
/* Should match: "foo" is a whole word in class="foo bar" */
5+
.foo.svelte-xyz { color: green; }
6+
7+
/* Should match: "bar" is a whole word in class="foo bar" */
8+
.bar.svelte-xyz { color: green; }
9+
10+
/* Should match: "foobar" is the whole class value */
11+
.foobar.svelte-xyz { color: green; }
12+
13+
/* Should match: "bar-foo" is a whole word (hyphen not whitespace) */
14+
.bar-foo.svelte-xyz { color: green; }
15+
16+
/* Should match: "baz" is a whole word in class="bar-foo baz" */
17+
.baz.svelte-xyz { color: green; }
18+
19+
/* Should NOT match: "foob" is not a word in any element's class */
20+
/* (unused) .foob { color: red; }*/
21+
22+
/* Should NOT match: "afoo" is a word but "foo-x" is not "foo" */
23+
[class~="foo-x"].svelte-xyz { color: green; }
24+
25+
/* Attribute selector with ~= operator directly */
26+
[class~="afoo"].svelte-xyz { color: green; }
27+
28+
/* === Deep combinator chains (4+ levels) === */
29+
30+
/* Should match: exact chain main > article > section > div > span */
31+
main.svelte-xyz > article:where(.svelte-xyz) > section:where(.svelte-xyz) > div:where(.svelte-xyz) > span:where(.svelte-xyz) { color: green; }
32+
33+
/* Should match: descendant chain */
34+
main.svelte-xyz article:where(.svelte-xyz) section:where(.svelte-xyz) div:where(.svelte-xyz) span:where(.svelte-xyz) { color: green; }
35+
36+
/* Should match: mixed combinators */
37+
main.svelte-xyz > article:where(.svelte-xyz) section:where(.svelte-xyz) > div:where(.svelte-xyz) span:where(.svelte-xyz) { color: green; }
38+
39+
/* Should NOT match: wrong nesting order */
40+
/* (unused) main > article > div > section > span { color: red; }*/
41+
42+
/* === :has() combined with other selectors === */
43+
44+
/* Should match: nav.primary has <a> descendant */
45+
nav:has(a:where(.svelte-xyz)).primary.svelte-xyz { color: green; }
46+
47+
/* Should match: nav.secondary has <button> descendant */
48+
nav:has(button:where(.svelte-xyz)).secondary.svelte-xyz { color: green; }
49+
50+
/* Should NOT match: nav.primary doesn't have <button> */
51+
/* (unused) nav:has(button).primary { color: red; }*/
52+
53+
/* Multiple :has() on same element */
54+
main.svelte-xyz:has(article:where(.svelte-xyz)):has(span:where(.svelte-xyz)) { color: green; }
55+
56+
/* :has() with child combinator */
57+
main.svelte-xyz:has(> article:where(.svelte-xyz)) { color: green; }
58+
59+
/* === Escaped selectors === */
60+
.a\-b.svelte-xyz { color: green; }
61+
62+
/* === :is()/:where()/:not() with deep selectors === */
63+
64+
/* :is() with matching selector */
65+
header.svelte-xyz :is(h1:where(.svelte-xyz)) { color: green; }
66+
67+
/* :where() with matching selector */
68+
ul.svelte-xyz :where(li:where(.svelte-xyz)) { color: green; }
69+
70+
/* :not() — should match span since it's not a div */
71+
span.svelte-xyz:not(div) { color: green; }
72+
73+
/* :is() with deep combinator */
74+
ul.svelte-xyz :is(li:where(.svelte-xyz) > span:where(.svelte-xyz)) { color: green; }
75+
76+
/* :not() with class — p.a-b is :not(.unused) */
77+
p.svelte-xyz:not(.unused) { color: green; }
78+
79+
/* Complex: :has() + :is() */
80+
ul.svelte-xyz:has(li:where(.svelte-xyz)) :is(span:where(.svelte-xyz)) { color: green; }

0 commit comments

Comments
 (0)