Skip to content

Commit eb6488c

Browse files
authored
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
1 parent a6c97b3 commit eb6488c

File tree

9 files changed

+442
-86
lines changed

9 files changed

+442
-86
lines changed

.changeset/silly-houses-promise.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+
breaking: scope `:has(...)` selectors

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

Lines changed: 179 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ const visitors = {
106106
selectors,
107107
/** @type {Compiler.Css.Rule} */ (node.metadata.rule),
108108
context.state.element,
109-
context.state.stylesheet
109+
context.state.stylesheet,
110+
true
110111
)
111112
) {
112113
mark(inner, context.state.element);
@@ -120,12 +121,18 @@ const visitors = {
120121
};
121122

122123
/**
123-
* Discard trailing `:global(...)` selectors, these are unused for scoping purposes
124+
* Discard trailing `:global(...)` selectors without a `:has(...)` modifier, these are unused for scoping purposes
124125
* @param {Compiler.Css.ComplexSelector} node
125126
*/
126127
function truncate(node) {
127-
const i = node.children.findLastIndex(({ metadata }) => {
128-
return !metadata.is_global && !metadata.is_global_like;
128+
const i = node.children.findLastIndex(({ metadata, selectors }) => {
129+
return (
130+
!metadata.is_global_like &&
131+
(!metadata.is_global ||
132+
selectors.some(
133+
(selector) => selector.type === 'PseudoClassSelector' && selector.name === 'has'
134+
))
135+
);
129136
});
130137

131138
return node.children.slice(0, i + 1);
@@ -136,9 +143,10 @@ function truncate(node) {
136143
* @param {Compiler.Css.Rule} rule
137144
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
138145
* @param {Compiler.Css.StyleSheet} stylesheet
146+
* @param {boolean} check_has Whether or not to check the `:has(...)` selectors
139147
* @returns {boolean}
140148
*/
141-
function apply_selector(relative_selectors, rule, element, stylesheet) {
149+
function apply_selector(relative_selectors, rule, element, stylesheet, check_has) {
142150
const parent_selectors = relative_selectors.slice();
143151
const relative_selector = parent_selectors.pop();
144152

@@ -148,88 +156,121 @@ function apply_selector(relative_selectors, rule, element, stylesheet) {
148156
relative_selector,
149157
rule,
150158
element,
151-
stylesheet
159+
stylesheet,
160+
check_has
152161
);
153162

154163
if (!possible_match) {
155164
return false;
156165
}
157166

158167
if (relative_selector.combinator) {
159-
const name = relative_selector.combinator.name;
160-
161-
switch (name) {
162-
case ' ':
163-
case '>': {
164-
let parent = /** @type {Compiler.TemplateNode | null} */ (element.parent);
168+
return apply_combinator(
169+
relative_selector.combinator,
170+
relative_selector,
171+
parent_selectors,
172+
rule,
173+
element,
174+
stylesheet,
175+
check_has
176+
);
177+
}
165178

166-
let parent_matched = false;
167-
let crossed_component_boundary = false;
179+
// if this is the left-most non-global selector, mark it — we want
180+
// `x y z {...}` to become `x.blah y z.blah {...}`
181+
const parent = parent_selectors[parent_selectors.length - 1];
182+
if (!parent || is_global(parent, rule)) {
183+
mark(relative_selector, element);
184+
}
168185

169-
while (parent) {
170-
if (parent.type === 'Component' || parent.type === 'SvelteComponent') {
171-
crossed_component_boundary = true;
172-
}
186+
return true;
187+
}
173188

174-
if (parent.type === 'RegularElement' || parent.type === 'SvelteElement') {
175-
if (apply_selector(parent_selectors, rule, parent, stylesheet)) {
176-
// TODO the `name === ' '` causes false positives, but removing it causes false negatives...
177-
if (name === ' ' || crossed_component_boundary) {
178-
mark(parent_selectors[parent_selectors.length - 1], parent);
179-
}
189+
/**
190+
* @param {Compiler.Css.Combinator} combinator
191+
* @param {Compiler.Css.RelativeSelector} relative_selector
192+
* @param {Compiler.Css.RelativeSelector[]} parent_selectors
193+
* @param {Compiler.Css.Rule} rule
194+
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
195+
* @param {Compiler.Css.StyleSheet} stylesheet
196+
* @param {boolean} check_has Whether or not to check the `:has(...)` selectors
197+
* @returns {boolean}
198+
*/
199+
function apply_combinator(
200+
combinator,
201+
relative_selector,
202+
parent_selectors,
203+
rule,
204+
element,
205+
stylesheet,
206+
check_has
207+
) {
208+
const name = combinator.name;
209+
210+
switch (name) {
211+
case ' ':
212+
case '>': {
213+
let parent = /** @type {Compiler.TemplateNode | null} */ (element.parent);
214+
215+
let parent_matched = false;
216+
let crossed_component_boundary = false;
217+
218+
while (parent) {
219+
if (parent.type === 'Component' || parent.type === 'SvelteComponent') {
220+
crossed_component_boundary = true;
221+
}
180222

181-
parent_matched = true;
223+
if (parent.type === 'RegularElement' || parent.type === 'SvelteElement') {
224+
if (apply_selector(parent_selectors, rule, parent, stylesheet, check_has)) {
225+
// TODO the `name === ' '` causes false positives, but removing it causes false negatives...
226+
if (name === ' ' || crossed_component_boundary) {
227+
mark(parent_selectors[parent_selectors.length - 1], parent);
182228
}
183229

184-
if (name === '>') return parent_matched;
230+
parent_matched = true;
185231
}
186232

187-
parent = /** @type {Compiler.TemplateNode | null} */ (parent.parent);
233+
if (name === '>') return parent_matched;
188234
}
189235

190-
return parent_matched || parent_selectors.every((selector) => is_global(selector, rule));
236+
parent = /** @type {Compiler.TemplateNode | null} */ (parent.parent);
191237
}
192238

193-
case '+':
194-
case '~': {
195-
const siblings = get_possible_element_siblings(element, name === '+');
239+
return parent_matched || parent_selectors.every((selector) => is_global(selector, rule));
240+
}
196241

197-
let sibling_matched = false;
242+
case '+':
243+
case '~': {
244+
const siblings = get_possible_element_siblings(element, name === '+');
198245

199-
for (const possible_sibling of siblings.keys()) {
200-
if (possible_sibling.type === 'RenderTag' || possible_sibling.type === 'SlotElement') {
201-
// `{@render foo()}<p>foo</p>` with `:global(.x) + p` is a match
202-
if (parent_selectors.length === 1 && parent_selectors[0].metadata.is_global) {
203-
mark(relative_selector, element);
204-
sibling_matched = true;
205-
}
206-
} else if (apply_selector(parent_selectors, rule, possible_sibling, stylesheet)) {
246+
let sibling_matched = false;
247+
248+
for (const possible_sibling of siblings.keys()) {
249+
if (possible_sibling.type === 'RenderTag' || possible_sibling.type === 'SlotElement') {
250+
// `{@render foo()}<p>foo</p>` with `:global(.x) + p` is a match
251+
if (parent_selectors.length === 1 && parent_selectors[0].metadata.is_global) {
207252
mark(relative_selector, element);
208253
sibling_matched = true;
209254
}
255+
} else if (
256+
apply_selector(parent_selectors, rule, possible_sibling, stylesheet, check_has)
257+
) {
258+
mark(relative_selector, element);
259+
sibling_matched = true;
210260
}
211-
212-
return (
213-
sibling_matched ||
214-
(get_element_parent(element) === null &&
215-
parent_selectors.every((selector) => is_global(selector, rule)))
216-
);
217261
}
218262

219-
default:
220-
// TODO other combinators
221-
return true;
263+
return (
264+
sibling_matched ||
265+
(get_element_parent(element) === null &&
266+
parent_selectors.every((selector) => is_global(selector, rule)))
267+
);
222268
}
223-
}
224269

225-
// if this is the left-most non-global selector, mark it — we want
226-
// `x y z {...}` to become `x.blah y z.blah {...}`
227-
const parent = parent_selectors[parent_selectors.length - 1];
228-
if (!parent || is_global(parent, rule)) {
229-
mark(relative_selector, element);
270+
default:
271+
// TODO other combinators
272+
return true;
230273
}
231-
232-
return true;
233274
}
234275

235276
/**
@@ -295,10 +336,87 @@ const regex_backslash_and_following_character = /\\(.)/g;
295336
* @param {Compiler.Css.Rule} rule
296337
* @param {Compiler.AST.RegularElement | Compiler.AST.SvelteElement} element
297338
* @param {Compiler.Css.StyleSheet} stylesheet
339+
* @param {boolean} check_has Whether or not to check the `:has(...)` selectors
298340
* @returns {boolean}
299341
*/
300-
function relative_selector_might_apply_to_node(relative_selector, rule, element, stylesheet) {
342+
function relative_selector_might_apply_to_node(
343+
relative_selector,
344+
rule,
345+
element,
346+
stylesheet,
347+
check_has
348+
) {
349+
// Sort :has(...) selectors in one bucket and everything else into another
350+
const has_selectors = [];
351+
const other_selectors = [];
352+
301353
for (const selector of relative_selector.selectors) {
354+
if (selector.type === 'PseudoClassSelector' && selector.name === 'has' && selector.args) {
355+
has_selectors.push(selector);
356+
} else {
357+
other_selectors.push(selector);
358+
}
359+
}
360+
361+
// If we're called recursively from a :has(...) selector, we're on the way of checking if the other selectors match.
362+
// In that case ignore this check (because we just came from this) to avoid an infinite loop.
363+
if (check_has && has_selectors.length > 0) {
364+
// :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
365+
// upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
366+
// selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
367+
for (const has_selector of has_selectors) {
368+
const complex_selectors = /** @type {Compiler.Css.SelectorList} */ (has_selector.args)
369+
.children;
370+
let matched = false;
371+
372+
for (const complex_selector of complex_selectors) {
373+
const selectors = truncate(complex_selector);
374+
if (
375+
selectors.length === 0 /* is :global(...) */ ||
376+
apply_selector(selectors, rule, element, stylesheet, check_has)
377+
) {
378+
// Treat e.g. `.x:has(.y)` as `.x .y` with the .y part already being matched,
379+
// and now looking upwards for the .x part.
380+
if (
381+
apply_combinator(
382+
selectors[0]?.combinator ?? descendant_combinator,
383+
selectors[0] ?? [],
384+
[relative_selector],
385+
rule,
386+
element,
387+
stylesheet,
388+
false
389+
)
390+
) {
391+
complex_selector.metadata.used = true;
392+
matched = true;
393+
}
394+
}
395+
}
396+
397+
if (!matched) {
398+
if (relative_selector.metadata.is_global && !relative_selector.metadata.is_global_like) {
399+
// Edge case: `:global(.x):has(.y)` where `.x` is global but `.y` doesn't match.
400+
// Since `used` is set to `true` for `:global(.x)` in css-analyze beforehand, and
401+
// we have no way of knowing if it's safe to set it back to `false`, we'll mark
402+
// the inner selector as used and scoped to prevent it from being pruned, which could
403+
// result in a invalid CSS output (e.g. `.x:has(/* unused .y */)`). The result
404+
// can't match a real element, so the only drawback is the missing prune.
405+
// TODO clean this up some day
406+
complex_selectors[0].metadata.used = true;
407+
complex_selectors[0].children.forEach((selector) => {
408+
selector.metadata.scoped = true;
409+
});
410+
}
411+
412+
return false;
413+
}
414+
}
415+
416+
return true;
417+
}
418+
419+
for (const selector of other_selectors) {
302420
if (selector.type === 'Percentage' || selector.type === 'Nth') continue;
303421

304422
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,
316434
) {
317435
const args = selector.args;
318436
const complex_selector = args.children[0];
319-
return apply_selector(complex_selector.children, rule, element, stylesheet);
437+
return apply_selector(complex_selector.children, rule, element, stylesheet, check_has);
320438
}
321439

322440
// 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,
326444
let matched = false;
327445

328446
for (const complex_selector of selector.args.children) {
329-
if (apply_selector(truncate(complex_selector), rule, element, stylesheet)) {
447+
if (apply_selector(truncate(complex_selector), rule, element, stylesheet, check_has)) {
330448
complex_selector.metadata.used = true;
331449
matched = true;
332450
}
@@ -400,7 +518,7 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
400518
const parent = /** @type {Compiler.Css.Rule} */ (rule.metadata.parent_rule);
401519

402520
for (const complex_selector of parent.prelude.children) {
403-
if (apply_selector(truncate(complex_selector), parent, element, stylesheet)) {
521+
if (apply_selector(truncate(complex_selector), parent, element, stylesheet, check_has)) {
404522
complex_selector.metadata.used = true;
405523
matched = true;
406524
}

packages/svelte/src/compiler/phases/3-transform/css/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ const visitors = {
311311
context.state.specificity.bumped = before_bumped;
312312
},
313313
PseudoClassSelector(node, context) {
314-
if (node.name === 'is' || node.name === 'where') {
314+
if (node.name === 'is' || node.name === 'where' || node.name === 'has') {
315315
context.next();
316316
}
317317
}

packages/svelte/src/compiler/types/css.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export namespace Css {
3030
type: 'Rule';
3131
prelude: SelectorList;
3232
block: Block;
33+
/** @internal */
3334
metadata: {
3435
parent_rule: null | Rule;
3536
has_local_selectors: boolean;
@@ -60,8 +61,10 @@ export namespace Css {
6061
* The `a`, `b` and `c` in `a b c {}`
6162
*/
6263
children: RelativeSelector[];
64+
/** @internal */
6365
metadata: {
6466
rule: null | Rule;
67+
/** True if this selector applies to an element. For global selectors, this is defined in css-analyze, for others in css-prune while scoping */
6568
used: boolean;
6669
};
6770
}
@@ -79,6 +82,7 @@ export namespace Css {
7982
* The `b:is(...)` in `> b:is(...)`
8083
*/
8184
selectors: SimpleSelector[];
85+
/** @internal */
8286
metadata: {
8387
/**
8488
* `true` if the whole selector is unscoped, e.g. `:global(...)` or `:global` or `:global.x`.

0 commit comments

Comments
 (0)