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