Skip to content

Commit 5669b31

Browse files
authored
fix: ensure :has selectors followed by other selectors match (#13824)
I resisted this previously because it felt a bit wasteful, but I now think that there's really no way around this: Instead of only going upwards the tree while matching, for `:has` we go _down_ the tree to see what matches. More specifically, we're collecting the children of the current element and then check if one of those does match the selectors inside `:has`. This makes the way the code works easier to reason about and also removes some boolean tracking we had to add for the previous approach. Fixes #13779
1 parent c603553 commit 5669b31

File tree

5 files changed

+96
-88
lines changed

5 files changed

+96
-88
lines changed

.changeset/strong-feet-happen.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+
fix: ensure `:has` selectors followed by other selectors match

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

Lines changed: 44 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,7 @@ const visitors = {
127127
selectors_to_check,
128128
/** @type {Compiler.Css.Rule} */ (node.metadata.rule),
129129
element,
130-
context.state.stylesheet,
131-
true
130+
context.state.stylesheet
132131
)
133132
) {
134133
mark(inner, element);
@@ -144,8 +143,7 @@ const visitors = {
144143
selectors,
145144
/** @type {Compiler.Css.Rule} */ (node.metadata.rule),
146145
context.state.element,
147-
context.state.stylesheet,
148-
true
146+
context.state.stylesheet
149147
)
150148
) {
151149
mark(inner, context.state.element);
@@ -191,28 +189,21 @@ function truncate(node) {
191189
* @param {Compiler.Css.Rule} rule
192190
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
193191
* @param {Compiler.Css.StyleSheet} stylesheet
194-
* @param {boolean} check_has Whether or not to check the `:has(...)` selectors
195192
* @returns {boolean}
196193
*/
197-
function apply_selector(relative_selectors, rule, element, stylesheet, check_has) {
194+
function apply_selector(relative_selectors, rule, element, stylesheet) {
198195
const parent_selectors = relative_selectors.slice();
199196
const relative_selector = parent_selectors.pop();
200197

201198
if (!relative_selector) return false;
202199

203200
const possible_match = relative_selector_might_apply_to_node(
204201
relative_selector,
205-
parent_selectors,
206202
rule,
207203
element,
208-
stylesheet,
209-
check_has
204+
stylesheet
210205
);
211206

212-
if (possible_match === 'definite_match') {
213-
return true;
214-
}
215-
216207
if (!possible_match) {
217208
return false;
218209
}
@@ -224,8 +215,7 @@ function apply_selector(relative_selectors, rule, element, stylesheet, check_has
224215
parent_selectors,
225216
rule,
226217
element,
227-
stylesheet,
228-
check_has
218+
stylesheet
229219
);
230220
}
231221

@@ -246,7 +236,6 @@ function apply_selector(relative_selectors, rule, element, stylesheet, check_has
246236
* @param {Compiler.Css.Rule} rule
247237
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
248238
* @param {Compiler.Css.StyleSheet} stylesheet
249-
* @param {boolean} check_has Whether or not to check the `:has(...)` selectors
250239
* @returns {boolean}
251240
*/
252241
function apply_combinator(
@@ -255,8 +244,7 @@ function apply_combinator(
255244
parent_selectors,
256245
rule,
257246
element,
258-
stylesheet,
259-
check_has
247+
stylesheet
260248
) {
261249
const name = combinator.name;
262250

@@ -281,7 +269,7 @@ function apply_combinator(
281269
}
282270

283271
if (parent.type === 'RegularElement' || parent.type === 'SvelteElement') {
284-
if (apply_selector(parent_selectors, rule, parent, stylesheet, check_has)) {
272+
if (apply_selector(parent_selectors, rule, parent, stylesheet)) {
285273
// TODO the `name === ' '` causes false positives, but removing it causes false negatives...
286274
if (name === ' ' || crossed_component_boundary) {
287275
mark(parent_selectors[parent_selectors.length - 1], parent);
@@ -312,9 +300,7 @@ function apply_combinator(
312300
mark(relative_selector, element);
313301
sibling_matched = true;
314302
}
315-
} else if (
316-
apply_selector(parent_selectors, rule, possible_sibling, stylesheet, check_has)
317-
) {
303+
} else if (apply_selector(parent_selectors, rule, possible_sibling, stylesheet)) {
318304
mark(relative_selector, element);
319305
sibling_matched = true;
320306
}
@@ -393,21 +379,12 @@ const regex_backslash_and_following_character = /\\(.)/g;
393379
* Ensure that `element` satisfies each simple selector in `relative_selector`
394380
*
395381
* @param {Compiler.Css.RelativeSelector} relative_selector
396-
* @param {Compiler.Css.RelativeSelector[]} parent_selectors
397382
* @param {Compiler.Css.Rule} rule
398383
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
399384
* @param {Compiler.Css.StyleSheet} stylesheet
400-
* @param {boolean} check_has Whether or not to check the `:has(...)` selectors
401-
* @returns {boolean | 'definite_match'}
385+
* @returns {boolean }
402386
*/
403-
function relative_selector_might_apply_to_node(
404-
relative_selector,
405-
parent_selectors,
406-
rule,
407-
element,
408-
stylesheet,
409-
check_has
410-
) {
387+
function relative_selector_might_apply_to_node(relative_selector, rule, element, stylesheet) {
411388
// Sort :has(...) selectors in one bucket and everything else into another
412389
const has_selectors = [];
413390
const other_selectors = [];
@@ -422,7 +399,26 @@ function relative_selector_might_apply_to_node(
422399

423400
// If we're called recursively from a :has(...) selector, we're on the way of checking if the other selectors match.
424401
// In that case ignore this check (because we just came from this) to avoid an infinite loop.
425-
if (check_has && has_selectors.length > 0) {
402+
if (has_selectors.length > 0) {
403+
/** @type {Array<Compiler.AST.RegularElement | Compiler.AST.SvelteElement>} */
404+
const child_elements = [];
405+
/** @type {Array<Compiler.AST.RegularElement | Compiler.AST.SvelteElement>} */
406+
const descendant_elements = [];
407+
408+
/**
409+
* @param {Compiler.SvelteNode} node
410+
* @param {boolean} is_recursing
411+
*/
412+
function collect_child_elements(node, is_recursing) {
413+
if (node.type === 'RegularElement' || node.type === 'SvelteElement') {
414+
descendant_elements.push(node);
415+
if (!is_recursing) child_elements.push(node);
416+
node.fragment.nodes.forEach((node) => collect_child_elements(node, true));
417+
}
418+
}
419+
420+
element.fragment.nodes.forEach((node) => collect_child_elements(node, false));
421+
426422
// :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
427423
// upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
428424
// selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
@@ -443,25 +439,20 @@ function relative_selector_might_apply_to_node(
443439
};
444440
}
445441

446-
if (
447-
selectors.length === 0 /* is :global(...) */ ||
448-
apply_selector(selectors, rule, element, stylesheet, check_has)
449-
) {
450-
// Treat e.g. `.x:has(.y)` as `.x .y` with the .y part already being matched,
451-
// and now looking upwards for the .x part.
442+
const descendants =
443+
left_most_combinator.name === '>' ? child_elements : descendant_elements;
444+
445+
let selector_matched = false;
446+
447+
// Iterate over all descendant elements and check if the selector inside :has matches
448+
for (const element of descendants) {
452449
if (
453-
apply_combinator(
454-
left_most_combinator,
455-
selectors[0] ?? [],
456-
[...parent_selectors, relative_selector],
457-
rule,
458-
element,
459-
stylesheet,
460-
false
461-
)
450+
selectors.length === 0 /* is :global(...) */ ||
451+
(element.metadata.scoped && selector_matched) ||
452+
apply_selector(selectors, rule, element, stylesheet)
462453
) {
463454
complex_selector.metadata.used = true;
464-
matched = true;
455+
selector_matched = matched = true;
465456
}
466457
}
467458
}
@@ -484,9 +475,6 @@ function relative_selector_might_apply_to_node(
484475
return false;
485476
}
486477
}
487-
488-
// We return this to signal the parent "don't bother checking the rest of the selectors, I already did that"
489-
return 'definite_match';
490478
}
491479

492480
for (const selector of other_selectors) {
@@ -507,7 +495,7 @@ function relative_selector_might_apply_to_node(
507495
) {
508496
const args = selector.args;
509497
const complex_selector = args.children[0];
510-
return apply_selector(complex_selector.children, rule, element, stylesheet, check_has);
498+
return apply_selector(complex_selector.children, rule, element, stylesheet);
511499
}
512500

513501
// We came across a :global, everything beyond it is global and therefore a potential match
@@ -520,7 +508,7 @@ function relative_selector_might_apply_to_node(
520508
const relative = truncate(complex_selector);
521509
if (
522510
relative.length === 0 /* is :global(...) */ ||
523-
apply_selector(relative, rule, element, stylesheet, check_has)
511+
apply_selector(relative, rule, element, stylesheet)
524512
) {
525513
complex_selector.metadata.used = true;
526514
matched = true;
@@ -621,7 +609,7 @@ function relative_selector_might_apply_to_node(
621609
const parent = /** @type {Compiler.Css.Rule} */ (rule.metadata.parent_rule);
622610

623611
for (const complex_selector of parent.prelude.children) {
624-
if (apply_selector(truncate(complex_selector), parent, element, stylesheet, check_has)) {
612+
if (apply_selector(truncate(complex_selector), parent, element, stylesheet)) {
625613
complex_selector.metadata.used = true;
626614
matched = true;
627615
}

packages/svelte/tests/css/samples/has/_config.js

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,112 +6,112 @@ export default test({
66
code: 'css_unused_selector',
77
message: 'Unused CSS selector ".unused:has(y)"',
88
start: {
9-
character: 269,
9+
line: 28,
1010
column: 1,
11-
line: 27
11+
character: 277
1212
},
1313
end: {
14-
character: 283,
14+
line: 28,
1515
column: 15,
16-
line: 27
16+
character: 291
1717
}
1818
},
1919
{
2020
code: 'css_unused_selector',
2121
message: 'Unused CSS selector ".unused:has(:global(y))"',
2222
start: {
23-
character: 304,
23+
line: 31,
2424
column: 1,
25-
line: 30
25+
character: 312
2626
},
2727
end: {
28-
character: 327,
28+
line: 31,
2929
column: 24,
30-
line: 30
30+
character: 335
3131
}
3232
},
3333
{
3434
code: 'css_unused_selector',
3535
message: 'Unused CSS selector "x:has(.unused)"',
3636
start: {
37-
character: 348,
37+
line: 34,
3838
column: 1,
39-
line: 33
39+
character: 356
4040
},
4141
end: {
42-
character: 362,
42+
line: 34,
4343
column: 15,
44-
line: 33
44+
character: 370
4545
}
4646
},
4747
{
4848
code: 'css_unused_selector',
4949
message: 'Unused CSS selector "x:has(y):has(.unused)"',
5050
start: {
51-
character: 517,
51+
line: 47,
5252
column: 1,
53-
line: 46
53+
character: 525
5454
},
5555
end: {
56-
character: 538,
56+
line: 47,
5757
column: 22,
58-
line: 46
58+
character: 546
5959
}
6060
},
6161
{
6262
code: 'css_unused_selector',
6363
message: 'Unused CSS selector ".unused"',
6464
start: {
65-
character: 743,
65+
line: 66,
6666
column: 2,
67-
line: 65
67+
character: 751
6868
},
6969
end: {
70-
character: 750,
70+
line: 66,
7171
column: 9,
72-
line: 65
72+
character: 758
7373
}
7474
},
7575
{
7676
code: 'css_unused_selector',
7777
message: 'Unused CSS selector ".unused x:has(y)"',
7878
start: {
79-
character: 897,
79+
line: 82,
8080
column: 1,
81-
line: 81
81+
character: 905
8282
},
8383
end: {
84-
character: 913,
84+
line: 82,
8585
column: 17,
86-
line: 81
86+
character: 921
8787
}
8888
},
8989
{
9090
code: 'css_unused_selector',
9191
message: 'Unused CSS selector ".unused:has(.unused)"',
9292
start: {
93-
line: 84,
93+
line: 85,
9494
column: 1,
95-
character: 934
95+
character: 942
9696
},
9797
end: {
98-
line: 84,
98+
line: 85,
9999
column: 21,
100-
character: 954
100+
character: 962
101101
}
102102
},
103103
{
104104
code: 'css_unused_selector',
105105
message: 'Unused CSS selector "x:has(> z)"',
106106
start: {
107-
line: 91,
107+
line: 92,
108108
column: 1,
109-
character: 1021
109+
character: 1029
110110
},
111111
end: {
112-
line: 91,
112+
line: 92,
113113
column: 11,
114-
character: 1031
114+
character: 1039
115115
}
116116
}
117117
]

packages/svelte/tests/css/samples/has/expected.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,10 @@
8888
x.svelte-xyz > y:where(.svelte-xyz):has(z:where(.svelte-xyz)) {
8989
color: green;
9090
}
91+
92+
x.svelte-xyz:has(y:where(.svelte-xyz)) z:where(.svelte-xyz) {
93+
color: green;
94+
}
95+
x.svelte-xyz:has(y:where(.svelte-xyz)) + c:where(.svelte-xyz) {
96+
color: green;
97+
}

0 commit comments

Comments
 (0)