From 0965028d3b1416744dde2c03bc07f6853f2aa5d0 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:49:36 +0100 Subject: [PATCH] perf: optimize CSS selector pruning (#17846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Rich Harris --- .changeset/fast-css-prune.md | 5 + .../phases/2-analyze/css/css-prune.js | 139 +++++++++++------- .../samples/css-prune-edge-cases/_config.js | 48 ++++++ .../samples/css-prune-edge-cases/expected.css | 80 ++++++++++ .../samples/css-prune-edge-cases/input.svelte | 125 ++++++++++++++++ 5 files changed, 347 insertions(+), 50 deletions(-) create mode 100644 .changeset/fast-css-prune.md create mode 100644 packages/svelte/tests/css/samples/css-prune-edge-cases/_config.js create mode 100644 packages/svelte/tests/css/samples/css-prune-edge-cases/expected.css create mode 100644 packages/svelte/tests/css/samples/css-prune-edge-cases/input.svelte diff --git a/.changeset/fast-css-prune.md b/.changeset/fast-css-prune.md new file mode 100644 index 0000000000..7b52272f9e --- /dev/null +++ b/.changeset/fast-css-prune.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +perf: optimize CSS selector pruning diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js index 24da276ed5..39f485a9f7 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js @@ -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()}

foo

` 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') { diff --git a/packages/svelte/tests/css/samples/css-prune-edge-cases/_config.js b/packages/svelte/tests/css/samples/css-prune-edge-cases/_config.js new file mode 100644 index 0000000000..c9d3f0cb5a --- /dev/null +++ b/packages/svelte/tests/css/samples/css-prune-edge-cases/_config.js @@ -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 + } + } + ] +}); diff --git a/packages/svelte/tests/css/samples/css-prune-edge-cases/expected.css b/packages/svelte/tests/css/samples/css-prune-edge-cases/expected.css new file mode 100644 index 0000000000..dbacb796bb --- /dev/null +++ b/packages/svelte/tests/css/samples/css-prune-edge-cases/expected.css @@ -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 descendant */ + nav:has(a:where(.svelte-xyz)).primary.svelte-xyz { color: green; } + + /* Should match: nav.secondary has + + + +

escaped

+ + +
+

title

+
+ + +