breaking: scope `:has(...)` selectors (#13567)

The main part of #13395

This implements scoping for selectors inside `:has(...)`. The approach is to first descend into the contents of a `:has(...)` selector, then in case of a match, try to match the rest of the selector ignoring the `:has(...)` part. In other words, `.x:has(y)` is essentially treated as `x y` with `y` being matched first, then walking up the selector chain taking into account combinators.

This is a breaking change because people could've used `:has(.unknown)` with `.unknown` not appearing in the HTML, and so they need to do `:has(:global(.unknown))` instead
pull/13606/head
Simon H 11 months ago committed by GitHub
parent a6c97b378f
commit eb6488cd90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
breaking: scope `:has(...)` selectors

@ -106,7 +106,8 @@ const visitors = {
selectors,
/** @type {Compiler.Css.Rule} */ (node.metadata.rule),
context.state.element,
context.state.stylesheet
context.state.stylesheet,
true
)
) {
mark(inner, context.state.element);
@ -120,12 +121,18 @@ const visitors = {
};
/**
* Discard trailing `:global(...)` selectors, these are unused for scoping purposes
* Discard trailing `:global(...)` selectors without a `:has(...)` modifier, these are unused for scoping purposes
* @param {Compiler.Css.ComplexSelector} node
*/
function truncate(node) {
const i = node.children.findLastIndex(({ metadata }) => {
return !metadata.is_global && !metadata.is_global_like;
const i = node.children.findLastIndex(({ metadata, selectors }) => {
return (
!metadata.is_global_like &&
(!metadata.is_global ||
selectors.some(
(selector) => selector.type === 'PseudoClassSelector' && selector.name === 'has'
))
);
});
return node.children.slice(0, i + 1);
@ -136,9 +143,10 @@ function truncate(node) {
* @param {Compiler.Css.Rule} rule
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
* @param {Compiler.Css.StyleSheet} stylesheet
* @param {boolean} check_has Whether or not to check the `:has(...)` selectors
* @returns {boolean}
*/
function apply_selector(relative_selectors, rule, element, stylesheet) {
function apply_selector(relative_selectors, rule, element, stylesheet, check_has) {
const parent_selectors = relative_selectors.slice();
const relative_selector = parent_selectors.pop();
@ -148,7 +156,8 @@ function apply_selector(relative_selectors, rule, element, stylesheet) {
relative_selector,
rule,
element,
stylesheet
stylesheet,
check_has
);
if (!possible_match) {
@ -156,80 +165,112 @@ function apply_selector(relative_selectors, rule, element, stylesheet) {
}
if (relative_selector.combinator) {
const name = relative_selector.combinator.name;
switch (name) {
case ' ':
case '>': {
let parent = /** @type {Compiler.TemplateNode | null} */ (element.parent);
return apply_combinator(
relative_selector.combinator,
relative_selector,
parent_selectors,
rule,
element,
stylesheet,
check_has
);
}
let parent_matched = false;
let crossed_component_boundary = false;
// if this is the left-most non-global selector, mark it — we want
// `x y z {...}` to become `x.blah y z.blah {...}`
const parent = parent_selectors[parent_selectors.length - 1];
if (!parent || is_global(parent, rule)) {
mark(relative_selector, element);
}
while (parent) {
if (parent.type === 'Component' || parent.type === 'SvelteComponent') {
crossed_component_boundary = true;
}
return true;
}
if (parent.type === 'RegularElement' || parent.type === 'SvelteElement') {
if (apply_selector(parent_selectors, rule, parent, stylesheet)) {
// TODO the `name === ' '` causes false positives, but removing it causes false negatives...
if (name === ' ' || crossed_component_boundary) {
mark(parent_selectors[parent_selectors.length - 1], parent);
}
/**
* @param {Compiler.Css.Combinator} combinator
* @param {Compiler.Css.RelativeSelector} relative_selector
* @param {Compiler.Css.RelativeSelector[]} parent_selectors
* @param {Compiler.Css.Rule} rule
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
* @param {Compiler.Css.StyleSheet} stylesheet
* @param {boolean} check_has Whether or not to check the `:has(...)` selectors
* @returns {boolean}
*/
function apply_combinator(
combinator,
relative_selector,
parent_selectors,
rule,
element,
stylesheet,
check_has
) {
const name = combinator.name;
switch (name) {
case ' ':
case '>': {
let parent = /** @type {Compiler.TemplateNode | null} */ (element.parent);
let parent_matched = false;
let crossed_component_boundary = false;
while (parent) {
if (parent.type === 'Component' || parent.type === 'SvelteComponent') {
crossed_component_boundary = true;
}
parent_matched = true;
if (parent.type === 'RegularElement' || parent.type === 'SvelteElement') {
if (apply_selector(parent_selectors, rule, parent, stylesheet, check_has)) {
// TODO the `name === ' '` causes false positives, but removing it causes false negatives...
if (name === ' ' || crossed_component_boundary) {
mark(parent_selectors[parent_selectors.length - 1], parent);
}
if (name === '>') return parent_matched;
parent_matched = true;
}
parent = /** @type {Compiler.TemplateNode | null} */ (parent.parent);
if (name === '>') return parent_matched;
}
return parent_matched || parent_selectors.every((selector) => is_global(selector, rule));
parent = /** @type {Compiler.TemplateNode | null} */ (parent.parent);
}
case '+':
case '~': {
const siblings = get_possible_element_siblings(element, name === '+');
return parent_matched || parent_selectors.every((selector) => is_global(selector, rule));
}
let sibling_matched = false;
case '+':
case '~': {
const siblings = get_possible_element_siblings(element, name === '+');
for (const possible_sibling of siblings.keys()) {
if (possible_sibling.type === 'RenderTag' || possible_sibling.type === 'SlotElement') {
// `{@render foo()}<p>foo</p>` with `:global(.x) + p` is a match
if (parent_selectors.length === 1 && parent_selectors[0].metadata.is_global) {
mark(relative_selector, element);
sibling_matched = true;
}
} else if (apply_selector(parent_selectors, rule, possible_sibling, stylesheet)) {
let sibling_matched = false;
for (const possible_sibling of siblings.keys()) {
if (possible_sibling.type === 'RenderTag' || possible_sibling.type === 'SlotElement') {
// `{@render foo()}<p>foo</p>` with `:global(.x) + p` is a match
if (parent_selectors.length === 1 && parent_selectors[0].metadata.is_global) {
mark(relative_selector, element);
sibling_matched = true;
}
} else if (
apply_selector(parent_selectors, rule, possible_sibling, stylesheet, check_has)
) {
mark(relative_selector, element);
sibling_matched = true;
}
return (
sibling_matched ||
(get_element_parent(element) === null &&
parent_selectors.every((selector) => is_global(selector, rule)))
);
}
default:
// TODO other combinators
return true;
return (
sibling_matched ||
(get_element_parent(element) === null &&
parent_selectors.every((selector) => is_global(selector, rule)))
);
}
}
// if this is the left-most non-global selector, mark it — we want
// `x y z {...}` to become `x.blah y z.blah {...}`
const parent = parent_selectors[parent_selectors.length - 1];
if (!parent || is_global(parent, rule)) {
mark(relative_selector, element);
default:
// TODO other combinators
return true;
}
return true;
}
/**
@ -295,10 +336,87 @@ const regex_backslash_and_following_character = /\\(.)/g;
* @param {Compiler.Css.Rule} rule
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
* @param {Compiler.Css.StyleSheet} stylesheet
* @param {boolean} check_has Whether or not to check the `:has(...)` selectors
* @returns {boolean}
*/
function relative_selector_might_apply_to_node(relative_selector, rule, element, stylesheet) {
function relative_selector_might_apply_to_node(
relative_selector,
rule,
element,
stylesheet,
check_has
) {
// Sort :has(...) selectors in one bucket and everything else into another
const has_selectors = [];
const other_selectors = [];
for (const selector of relative_selector.selectors) {
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 (check_has && has_selectors.length > 0) {
// :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.Css.SelectorList} */ (has_selector.args)
.children;
let matched = false;
for (const complex_selector of complex_selectors) {
const selectors = truncate(complex_selector);
if (
selectors.length === 0 /* is :global(...) */ ||
apply_selector(selectors, rule, element, stylesheet, check_has)
) {
// Treat e.g. `.x:has(.y)` as `.x .y` with the .y part already being matched,
// and now looking upwards for the .x part.
if (
apply_combinator(
selectors[0]?.combinator ?? descendant_combinator,
selectors[0] ?? [],
[relative_selector],
rule,
element,
stylesheet,
false
)
) {
complex_selector.metadata.used = true;
matched = true;
}
}
}
if (!matched) {
if (relative_selector.metadata.is_global && !relative_selector.metadata.is_global_like) {
// Edge case: `:global(.x):has(.y)` where `.x` is global but `.y` doesn't match.
// Since `used` is set to `true` for `:global(.x)` in css-analyze beforehand, and
// we have no way of knowing if it's safe to set it back to `false`, we'll mark
// the inner selector as used and scoped to prevent it from being pruned, which could
// result in a invalid CSS output (e.g. `.x:has(/* unused .y */)`). The result
// can't match a real element, so the only drawback is the missing prune.
// TODO clean this up some day
complex_selectors[0].metadata.used = true;
complex_selectors[0].children.forEach((selector) => {
selector.metadata.scoped = true;
});
}
return false;
}
}
return true;
}
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');
@ -316,7 +434,7 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
) {
const args = selector.args;
const complex_selector = args.children[0];
return apply_selector(complex_selector.children, rule, element, stylesheet);
return apply_selector(complex_selector.children, rule, element, stylesheet, check_has);
}
// We came across a :global, everything beyond it is global and therefore a potential match
@ -326,7 +444,7 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
let matched = false;
for (const complex_selector of selector.args.children) {
if (apply_selector(truncate(complex_selector), rule, element, stylesheet)) {
if (apply_selector(truncate(complex_selector), rule, element, stylesheet, check_has)) {
complex_selector.metadata.used = true;
matched = true;
}
@ -400,7 +518,7 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
const parent = /** @type {Compiler.Css.Rule} */ (rule.metadata.parent_rule);
for (const complex_selector of parent.prelude.children) {
if (apply_selector(truncate(complex_selector), parent, element, stylesheet)) {
if (apply_selector(truncate(complex_selector), parent, element, stylesheet, check_has)) {
complex_selector.metadata.used = true;
matched = true;
}

@ -311,7 +311,7 @@ const visitors = {
context.state.specificity.bumped = before_bumped;
},
PseudoClassSelector(node, context) {
if (node.name === 'is' || node.name === 'where') {
if (node.name === 'is' || node.name === 'where' || node.name === 'has') {
context.next();
}
}

@ -30,6 +30,7 @@ export namespace Css {
type: 'Rule';
prelude: SelectorList;
block: Block;
/** @internal */
metadata: {
parent_rule: null | Rule;
has_local_selectors: boolean;
@ -60,8 +61,10 @@ export namespace Css {
* The `a`, `b` and `c` in `a b c {}`
*/
children: RelativeSelector[];
/** @internal */
metadata: {
rule: null | Rule;
/** True if this selector applies to an element. For global selectors, this is defined in css-analyze, for others in css-prune while scoping */
used: boolean;
};
}
@ -79,6 +82,7 @@ export namespace Css {
* The `b:is(...)` in `> b:is(...)`
*/
selectors: SimpleSelector[];
/** @internal */
metadata: {
/**
* `true` if the whole selector is unscoped, e.g. `:global(...)` or `:global` or `:global.x`.

@ -0,0 +1,90 @@
import { test } from '../../test';
export default test({
warnings: [
{
code: 'css_unused_selector',
message: 'Unused CSS selector ".unused:has(y)"',
start: {
character: 269,
column: 1,
line: 27
},
end: {
character: 283,
column: 15,
line: 27
}
},
{
code: 'css_unused_selector',
message: 'Unused CSS selector ".unused:has(:global(y))"',
start: {
character: 304,
column: 1,
line: 30
},
end: {
character: 327,
column: 24,
line: 30
}
},
{
code: 'css_unused_selector',
message: 'Unused CSS selector "x:has(.unused)"',
start: {
character: 348,
column: 1,
line: 33
},
end: {
character: 362,
column: 15,
line: 33
}
},
{
code: 'css_unused_selector',
message: 'Unused CSS selector "x:has(y):has(.unused)"',
start: {
character: 517,
column: 1,
line: 46
},
end: {
character: 538,
column: 22,
line: 46
}
},
{
code: 'css_unused_selector',
message: 'Unused CSS selector ".unused"',
start: {
character: 743,
column: 2,
line: 65
},
end: {
character: 750,
column: 9,
line: 65
}
},
{
code: 'css_unused_selector',
message: 'Unused CSS selector ".unused x:has(y)"',
start: {
character: 897,
column: 1,
line: 81
},
end: {
character: 913,
column: 17,
line: 81
}
}
]
});

@ -0,0 +1,77 @@
x.svelte-xyz:has(y:where(.svelte-xyz)) {
color: green;
}
x.svelte-xyz:has(z:where(.svelte-xyz)) {
color: green;
}
x.svelte-xyz:has(y) {
color: green;
}
x.svelte-xyz:has(z) {
color: green;
}
x.svelte-xyz:has(.foo) {
color: green;
}
.foo:has(y.svelte-xyz) {
color: green;
}
/* (unused) .unused:has(y) {
color: red;
}*/
/* (unused) .unused:has(:global(y)) {
color: red;
}*/
/* (unused) x:has(.unused) {
color: red;
}*/
.foo:has(.unused.svelte-xyz) {
color: red;
}
x.svelte-xyz:has(y:where(.svelte-xyz) /* (unused) .unused*/) {
color: green;
}
x.svelte-xyz:has(y:where(.svelte-xyz), .foo) {
color: green;
}
/* (unused) x:has(y):has(.unused) {
color: red;
}*/
x.svelte-xyz:has(y:where(.svelte-xyz)):has(.foo) {
color: green;
}
x.svelte-xyz:has(y:where(.svelte-xyz)) z:where(.svelte-xyz) {
color: green;
}
x.svelte-xyz:has(y:where(.svelte-xyz)) {
z:where(.svelte-xyz) {
color: green;
}
}
x.svelte-xyz:has(y:where(.svelte-xyz)) .foo {
color: green;
}
/* (empty) x:has(y) {
.unused {
color: red;
}
}*/
x.svelte-xyz y:where(.svelte-xyz):has(z:where(.svelte-xyz)) {
color: green;
}
x.svelte-xyz {
y:where(.svelte-xyz):has(z:where(.svelte-xyz)) {
color: green;
}
}
.foo x.svelte-xyz:has(y:where(.svelte-xyz)) {
color: green;
}
/* (unused) .unused x:has(y) {
color: red;
}*/

@ -0,0 +1,84 @@
<x>
<y>
<z></z>
</y>
</x>
<style>
x:has(y) {
color: green;
}
x:has(z) {
color: green;
}
x:has(:global(y)) {
color: green;
}
x:has(:global(z)) {
color: green;
}
x:has(:global(.foo)) {
color: green;
}
:global(.foo):has(y) {
color: green;
}
.unused:has(y) {
color: red;
}
.unused:has(:global(y)) {
color: red;
}
x:has(.unused) {
color: red;
}
:global(.foo):has(.unused) {
color: red;
}
x:has(y, .unused) {
color: green;
}
x:has(y, :global(.foo)) {
color: green;
}
x:has(y):has(.unused) {
color: red;
}
x:has(y):has(:global(.foo)) {
color: green;
}
x:has(y) z {
color: green;
}
x:has(y) {
z {
color: green;
}
}
x:has(y) :global(.foo) {
color: green;
}
x:has(y) {
.unused {
color: red;
}
}
x y:has(z) {
color: green;
}
x {
y:has(z) {
color: green;
}
}
:global(.foo) x:has(y) {
color: green;
}
.unused x:has(y) {
color: red;
}
</style>

@ -1299,14 +1299,6 @@ declare module 'svelte/compiler' {
type: 'Rule';
prelude: SelectorList;
block: Block;
metadata: {
parent_rule: null | Rule;
has_local_selectors: boolean;
/**
* `true` if the rule contains a `:global` selector, and therefore everything inside should be unscoped
*/
is_global_block: boolean;
};
}
/**
@ -1329,10 +1321,6 @@ declare module 'svelte/compiler' {
* The `a`, `b` and `c` in `a b c {}`
*/
children: RelativeSelector[];
metadata: {
rule: null | Rule;
used: boolean;
};
}
/**
@ -1348,16 +1336,6 @@ declare module 'svelte/compiler' {
* The `b:is(...)` in `> b:is(...)`
*/
selectors: SimpleSelector[];
metadata: {
/**
* `true` if the whole selector is unscoped, e.g. `:global(...)` or `:global` or `:global.x`.
* Selectors like `:global(...).x` are not considered global, because they still need scoping.
*/
is_global: boolean;
/** `:root`, `:host`, `::view-transition`, or selectors after a `:global` */
is_global_like: boolean;
scoped: boolean;
};
}
export interface TypeSelector extends BaseNode {

@ -293,9 +293,9 @@ Svelte 5 is more strict about the HTML structure and will throw a compiler error
Assignments to destructured parts of a `@const` declaration are no longer allowed. It was an oversight that this was ever allowed.
### :is(...) and :where(...) are scoped
### :is(...), :where(...) and :has(...) are scoped
Previously, Svelte did not analyse selectors inside `:is(...)` and `:where(...)`, effectively treating them as global. Svelte 5 analyses them in the context of the current component. As such, some selectors may now be treated as unused if they were relying on this treatment. To fix this, use `:global(...)` inside the `:is(...)/:where(...)` selectors.
Previously, Svelte did not analyse selectors inside `:is(...)`, `:where(...)` and `:has(...)`, effectively treating them as global. Svelte 5 analyses them in the context of the current component. As such, some selectors may now be treated as unused if they were relying on this treatment. To fix this, use `:global(...)` inside the `:is(...)/:where(...)/:has(...)` selectors.
When using Tailwind's `@apply` directive, add a `:global` selector to preserve rules that use Tailwind-generated `:is(...)` selectors:

Loading…
Cancel
Save