Skip to content

Commit 08b3a74

Browse files
committed
breaking: scope :has(...) selectors
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 3f0b41b commit 08b3a74

File tree

8 files changed

+449
-64
lines changed

8 files changed

+449
-64
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: 189 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} separate_has Whether or not to separate 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, separate_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+
separate_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+
separate_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} separate_has
197+
* @returns {boolean}
198+
*/
199+
function apply_combinator(
200+
combinator,
201+
relative_selector,
202+
parent_selectors,
203+
rule,
204+
element,
205+
stylesheet,
206+
separate_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, separate_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+
}
241+
242+
case '+':
243+
case '~': {
244+
const siblings = get_possible_element_siblings(element, name === '+');
196245

197-
let sibling_matched = false;
246+
let sibling_matched = false;
198247

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

304428
const name = selector.name.replace(regex_backslash_and_following_character, '$1');
@@ -316,7 +440,7 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
316440
) {
317441
const args = selector.args;
318442
const complex_selector = args.children[0];
319-
return apply_selector(complex_selector.children, rule, element, stylesheet);
443+
return apply_selector(complex_selector.children, rule, element, stylesheet, separate_has);
320444
}
321445

322446
// We came across a :global, everything beyond it is global and therefore a potential match
@@ -326,7 +450,9 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
326450
let matched = false;
327451

328452
for (const complex_selector of selector.args.children) {
329-
if (apply_selector(truncate(complex_selector), rule, element, stylesheet)) {
453+
if (
454+
apply_selector(truncate(complex_selector), rule, element, stylesheet, separate_has)
455+
) {
330456
complex_selector.metadata.used = true;
331457
matched = true;
332458
}
@@ -400,7 +526,9 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
400526
const parent = /** @type {Compiler.Css.Rule} */ (rule.metadata.parent_rule);
401527

402528
for (const complex_selector of parent.prelude.children) {
403-
if (apply_selector(truncate(complex_selector), parent, element, stylesheet)) {
529+
if (
530+
apply_selector(truncate(complex_selector), parent, element, stylesheet, separate_has)
531+
) {
404532
complex_selector.metadata.used = true;
405533
matched = true;
406534
}

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export namespace Css {
6262
children: RelativeSelector[];
6363
metadata: {
6464
rule: null | Rule;
65+
/** True if this selector applies to an element. For global selectors, this is defined in css-analyze, for others in css-prune while scoping */
6566
used: boolean;
6667
};
6768
}

0 commit comments

Comments
 (0)