[go: nahoru, domu]

Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-nesting] How to resolve nested CSS with pseudo elements in the parent #7433

Closed
romainmenke opened this issue Jun 29, 2022 · 36 comments
Closed
Labels
Closed Accepted by Editor Discretion Commenter Satisfied Commenter has indicated satisfaction with the resolution / edits. css-nesting-1 Current Work

Comments

@romainmenke
Copy link
Member
romainmenke commented Jun 29, 2022

first reported here : csstools/postcss-plugins#510

relevant specification parts:

https://www.w3.org/TR/css-nesting-1/#nest-selector

The nesting selector can be desugared by replacing it with the parent style rule’s selector, wrapped in an :is() selector. For example,

https://www.w3.org/TR/selectors-4/#matches-pseudo

Pseudo-elements cannot be represented by the matches-any pseudo-class; they are not valid within :is().

For this CSS :

.anything::before {
  @nest .something_else > & {
    color: black
  }
}

& should represent the matched element of the enclosing rule.
Which in this case is before.

This CSS :

  1. is invalid
  2. is valid but never matches
  3. matches .something_else > .anything::before
  4. matches .something_else.anything::before (or .something_else.anything :> before)

Also relevant :

@romainmenke
Copy link
Member Author

cc @argyleink @tabatkins

@argyleink
Copy link
Contributor

I think you did the right thing reverting the change that tried to correct it for developers. I'm thinking wait/lean on devtools to help developers learn that :is() and :where() can't accept pseudo elements? add it to a style linter to watch out for? or the postcss plugin could notice nested :: and warn in the console?

agree that this is a tough lesson in nesting, since the 1st lesson to be learned with is and where feels out of no where. heh, no:where()..

i believe this is how the nested selectors can be rewritten.

.anything {
  &::before {}

  @nest .something_else > &::before {
    color: black
  }
}

this makes me think we could add a warning to the spec that any nesting inside a ::before (or of same type) will produce an invalid selector per https://www.w3.org/TR/selectors-4/#matches-pseudo and have no matches.

@romainmenke
Copy link
Member Author

Adding a warning to the spec would be great! Anything that is invalid in :is is also invalid as a nesting ancestor (ancestor of the rule, not of the matched element)

At the moment this is implied but not clear beyond any possible confusion.

It's true that with nesting specifically stylesheet authors need to re-learn the feature and this is a painful process :/

It was inspired by sass nesting but it is completely different from that.
The first versions of the PostCSS plugin did something different altogether.

So stylesheet authors have an intuitive sense of how the feature should work that doesn't match how it will eventually work in browsers.

We already took some steps with the last major version of the PostCSS plugin but clearly more work is needed :)


A (style)lint rule that can spot invalid nesting would be awesome and doesn't exist yet.
This also gives faster feedback while typing.

@cdoublev
Copy link
Collaborator
cdoublev commented Jul 15, 2022

I am not sure I understand why ::before { &:hover {} } would be invalid. I do not claim it should. Even :is() not accepting pseudo-elements is not something already defined, I think. But I'm reading the note about the :is() desugaring trick as intended only for CSS processing tools authors.

@johannesodland
Copy link

Adding a warning to the spec would be great! Anything that is invalid in :is is also invalid as a nesting ancestor (ancestor of the rule, not of the matched element)

As an author, I hope we don't drop nesting inside pseudo-elements just because it's not supported in :is() today.
There are many use cases for nesting in pseudo-elements, and there will be more as more nested pseudo-elements are added.

Nested conditional rules

div::before {
  color: blue;

  @media (min-width: 480px) {
    color: red;
  }
}

**Nested user action pseudo classes **

div::before {
  color: blue;

  &:hover {
    color: red;
  }
}

Nested pseudo-elements

article::fist-letter {
  initial-letter: 3;

  &::prefix {
    font-size: 0.5em;
    vertical-align: top;
  }
}

It's not just that we are used to the existing feature. Making nesting inside pseudo-elements invalid will block many use cases, and will make it harder to teach and understand nesting. The specificity side-effects of using :is() will be surprising enough as it is.

I understand making it invalid would make the feature easier to implement through :is() at the moment. I hope we can prioritise authors over ease of implementation, and perhaps look at extending support for nested pseudo-elements in :is(). Improved syntax for selecting pseudo-element children and descendants as described in 7346 would be great too.

@tabatkins
Copy link
Member

The :is() desugaring, taken literally, just advice for tools, yeah. We don't actually produce an :is() internally. But the restrictions should exist in both places or neither.

@sesse
Copy link
Contributor
sesse commented Oct 20, 2022

FWIW, while the implementation in Blink doesn't literally produce an :is() internally (it couldn't, due to CSSOM demands), it produces something that is awfully close. It goes through the exact same path while matching, for instance. (See https://chromium-review.googlesource.com/c/chromium/src/+/3934883/16/third_party/blink/renderer/core/css/selector_checker.cc#1315 if you're interested)

@romainmenke
Copy link
Member Author
romainmenke commented Oct 21, 2022

TL;DR;

@supports selector(:is(::before)) and selector(& p) seems to be a valid way to check for support in the future if this remains true :

But the restrictions should exist in both places or neither.


I was looking at this from a feature detection perspective and I think a couple of things intersect here.

  • :is() is not forgiving in @supports, which was a recent change I think
  • you can not test for a nesting pattern with rules in @supports (e.g. @supports selector(::before { & {} }))

Chrome 106:

  • @supports selector(:is(::before)) : true
  • @supports selector(& p) : false
  • @supports selector(:is(::before)) and selector(& p) : false

Chrome 109:

  • @supports selector(:is(::before)) : false
  • @supports selector(& p) : true
  • @supports selector(:is(::before)) and selector(& p) : false
<!DOCTYPE html>
<html lang="en">
<head>
	<title>Document</title>
	<style>
		html {
			& p { /* this is irrelevant, I just wanted to nest something */
				color: green;
			}
		}

		@supports selector(& p) {
			p {
				/* Version 109.0.5372.0 (Official Build) canary (arm64) */
				/* Experimental platform features on */
				text-decoration: underline solid green;
			}
		}

		@supports selector(:is(::before)) {
			p {
				/* Version 106.0.5249.119 (Official Build) (arm64) */
				text-decoration: underline wavy red;
			}
		}

		@supports selector(:is(::before)) and selector(& p) {
			p {
				text-decoration: underline dotted purple;
			}
		}
	</style>
</head>
<body>
	<p>Hello!</p>
</body>
</html>

@LeaVerou
Copy link
Member
LeaVerou commented Oct 21, 2022

Conceptually, there is no reason to disallow this:

.anything::before {
  .something_else > & {
    color: black
  }
}

So it would be unfortunate if we need to for what is basically an implementation detail. Perhaps we can just change how selectors get rewritten? Or maybe we could allow pseudo-elements in :is() and :where() for the cases where it makes sense?

@romainmenke
Copy link
Member Author

Perhaps we can just change how selectors get rewritten?

Can you clarify?

@LeaVerou
Copy link
Member

Perhaps we can just change how selectors get rewritten?

Can you clarify?

E.g.

.a, .b::before {
	.c > & {
		color: black
	}
}

could be rewritten as:

.c > .a,
.c > .b::before {
	color: black
}

@romainmenke
Copy link
Member Author
romainmenke commented Oct 21, 2022

Yes, but your comment hints at a larger change.

An example where :is() is important :

.parent .a, .other-parent .b::before {
	.c > & {
		color: black
	}
}

currently matches like :

.c > :is(.parent .a), .c > :is(.other-parent .b::before) {
	color: black
}

Which is also why I agree with @tabatkins to keep these linked.
Resolving this issue for both nesting and :is() at the same time is the least confusing.

@tabatkins
Copy link
Member

"How selectors get rewritten" has only a few possibiltiies:

  1. Use :is().
  2. Fully expand the selectors, possibly resulting in a combinatorial explosion of selector length. (.a .b .c { .d .e & {...}} expands to, hm, 5-choose-2 selectors (10 total, in this case).
  3. Use something exactly like :is() except it allows more stuff.

We're currently doing 1 (effectively). 2 is right out. 3 just raises the question of why we can't do that stuff in :is().

@johannesodland
Copy link

If I’m not mistaken, precompilers used to fully expand nested selectors. I’m not sure if it was a huge problem?

If nested rules were fully expanded, we could rely on linters to warn authors of the expanded complexity. :is() can then be added selectively where needed.

I think I might prefer this over nesting not working inside pseudo-elements and the unexpected specificity. This would also need linting to warn about invalid use.

@romainmenke
Copy link
Member Author
romainmenke commented Oct 21, 2022

preprocessor tools come in roughly two categories : scss-like nesting and those that attempt to follow the specification.

scss-like nesting doesn't have this issue at all because they manipulate selector bits as strings. .foo { &--bar } -> .foo--bar {}

Those that attempt to follow the specification had/have bugs and deviations.
A recent attempt to align postcss-nesting with the specification is why I opened this issue in the first place.

No tool that I know of ever fully expanded nested selectors as would be required to follow the specification.

I think I might prefer this over nesting not working inside pseudo-elements and the unexpected specificity.

I think everyone agrees that this should work (from an author perspective) and that it is more a matter of finding the right way and time to fix this.

@sesse
Copy link
Contributor
sesse commented Oct 21, 2022

I think everyone agrees that this should work

I don't agree this should be solved for css-nesting-1. I'm fine with some followup spec (be it css-nesting or css-selectors) making :is() more flexible (thus trickling down to nesting), as long as the edge cases are clearly worked out.

@romainmenke
Copy link
Member Author
romainmenke commented Oct 21, 2022

I don't agree this should be solved for css-nesting-1.

My intention was to clarify that not fixing it now (in css-nesting-1) doesn't mean it won't ever be updated. I've updated my comment.

@LeaVerou
Copy link
Member

3 just raises the question of why we can't do that stuff in :is().

Indeed, why?

@Loirooriol
Copy link
Contributor

Conceptually, there is no reason to disallow this:

.anything::before { .something_else > & { color: black  }}

@LeaVerou I disagree: the reason is that ::before pseudo-elements cannot be children (in the DOM).

:: is like a combinator, so these are different:

.something_else > .anything::before { color: black  }
.anything::before { .something_else > & { color: black  }}

Just like these are different:

.something_else > .anything > before { color: black  }
.anything > before { .something_else > & { color: black  }}

@LeaVerou
Copy link
Member
LeaVerou commented Oct 21, 2022

@Loirooriol :: is not like a combinator. You cannot rearrange things like you can for combinators, ::before is a single token. In the second example, the .something else and .anything can be the same element in the nested selector, but not in the non-nested one. However, no such thing is true for the former.

@Loirooriol
Copy link
Contributor

Strictly speaking is not a combinator but it kinda behaves like a combinator and probably should have been a combinator and #7346 resolved to work on actual combinators for accessing pseudo-elements.

I don't get your rearranging argument, .foo > .bar > .baz can't be rearranged either as .foo > .baz > .bar or something, it's normal not being able to rearrange complex selectors. That's actually an argument in favor of :: behaving kinda like a combinator.

@romainmenke
Copy link
Member Author
romainmenke commented Oct 21, 2022

That's actually an argument in favor of :: behaving kinda like a combinator.

yup : .foo::before:hover vs. .foo:hover::before

:: -> "into pseudo element realm"

You cannot add spaces around it (.foo :: before) but .foo::before must be treated as a complex selector, not a compound.

@LeaVerou
Copy link
Member

Strictly speaking is not a combinator but it kinda behaves like a combinator and probably should have been a combinator and #7346 resolved to work on actual combinators for accessing pseudo-elements.

I don't get your rearranging argument, .foo > .bar > .baz can't be rearranged either as .foo > .baz > .bar or something, it's normal not being able to rearrange complex selectors. That's actually an argument in favor of :: behaving kinda like a combinator.

I didn't mean that kind of rearranging, but that with combinators you can do things like .foo > :is(.bar) > .baz, adjust whitespace etc because they are separate tokens. I'm still missing the explanation on how your pseudo-element example differs between the nested and non-nested version.

@tabatkins
Copy link
Member

If I’m not mistaken, precompilers used to fully expand nested selectors. I’m not sure if it was a huge problem?

They did not, precisely because this is a huge problem. Sass uses some heuristics to guess what subset of the possible selectors to actually emit. I presume other langs do so as well (or just have a combinatorial explosion in their output sometimes).

I don't agree this should be solved for css-nesting-1. I'm fine with some followup spec (be it css-nesting or css-selectors) making :is() more flexible (thus trickling down to nesting), as long as the edge cases are clearly worked out.

Yup, that's my intent as well.

@Loirooriol
Copy link
Contributor
Loirooriol commented Oct 21, 2022

@LeaVerou The difference is that .something_else > .anything::before means: select the before pseudo-elements originated by an element with class anything which is a child of an element with class something_else.

And .anything::before { .something_else > & means: select the things which are children of an element with class something_else if they also happen to be before pseudo-elements originated by an element with class anything. And it's not possible for a before pseudo-element to be a child of an element in the DOM (since the pseudo-element doesn't exist in the DOM), so this won't match anything.

I think .anything::before { &:hover { seems doable: basically a matter of letting & select whatever matches the parent selector, regardless of whether it's an element or a pseudo-element. But I don't see how .anything::before { .something_else > & can work in a sane way without fully expanding the selectors.

@johannesodland
Copy link

They did not, precisely because this is a huge problem.

I’m sorry, my mental model was off. I didn’t see the full complexity.

Adding an explanation of why .anything::before { .something_else > & is invalid to the spec would be helpful.

Linting and devtools will help, and hopefully nesting in pseudo-elements / a more flexible :is() can be solved in a later spec 🤞🏼

@Loirooriol
Copy link
Contributor

Fully expand the selectors, possibly resulting in a combinatorial explosion of selector length. (.a .b .c { .d .e & {...}} expands to, hm, 5-choose-2 selectors (10 total, in this case).

Not that it matters much, but the explosion is worse than that, because the simple selectors can refer to the same element: .a .b .c .d .e, .a .b .c.d .e, .a .b .c .d .e, .a .b.d .c .e... I think that's 25 in total. So yeah, fully expanding is bad.

@tabatkins
Copy link
Member

Right, I allowed for that in my calculation - that's why it's 5-choose-2. Note that the expanded selector has to end with .c on its own; you're just merging the .a .b and .d .e preludes together, and there are five locations in the first selector (each compound, and between/before/after each) where you can drop in the two compounds of the second selector, so 5-choose-2 combos in total.

@tabatkins
Copy link
Member

I now have an explicit restriction against & representing pseudo-elements in the spec, along with an issue noting that we'd like to fix it, but need to do so simultaneously for :is() since they rely on the same mechanisms.

@sesse
Copy link
Contributor
sesse commented Oct 27, 2022

Thanks!

@tabatkins tabatkins added Commenter Satisfied Commenter has indicated satisfaction with the resolution / edits. and removed Commenter Response Pending labels Oct 27, 2022
@matthew-dean
Copy link

@tabatkins

I know this is an old conversation, but just FWIW:

They did not, precisely because this is a huge problem. Sass uses some heuristics to guess what subset of the possible selectors to actually emit. I presume other langs do so as well (or just have a combinatorial explosion in their output sometimes).

Less essentially clamps the number of combinations you can have. Otherwise it's a terrible problem, and at least in Less threads / documentation, we warn authors that this is one of the dangers of over-nesting lists.

Interestingly, I was going to open a suggestion in Less to, at some point, output :is() wrappers instead of combining selectors. Although this may not be necessary since it could just pass through nesting.

@matthew-dean
Copy link

Interestingly, I was going to open a suggestion in Less to, at some point, output :is() wrappers instead of combining selectors.

Hang on, pseudo-elements are not valid in :is()? 🤔 Why?

@tabatkins
Copy link
Member

Because an element is never a pseudo-element (and you can't generically select a pseudo-element and then test its name in an :is()).

That is, div:is(::before) is asking "is the div a ::before?" and that's never true (and similarly, ::before:is(::before) is either always or never true, depending on if the names match or not). What people expect it to mean is "change the subject of the selector to the div's ::before pseudo", but pseudo-classes can't change the subject; you need a combinator for that.

Ultimately the problem is that early CSS screwed up the pseudo-element syntax, making it look like a pseudo-class (literally spelled it :before; the double-colon came later) rather than like a combinator+name. (There's some other issues to potentially fix that, tho I can't dig them up at the moment.)

@yisibl
Copy link
Contributor
yisibl commented Jun 20, 2024

This limitation is sure to confuse more and more authors, even developers of browser engines, see https://issues.chromium.org/issues/40278599

@LeaVerou
Copy link
Member

@yisibl can you please post this in 9492 which is the actual open issue for this?

@yisibl
Copy link
Contributor
yisibl commented Jun 20, 2024

@yisibl can you please post this in 9492 which is the actual open issue for this?

No problem. Done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Closed Accepted by Editor Discretion Commenter Satisfied Commenter has indicated satisfaction with the resolution / edits. css-nesting-1 Current Work
Projects
None yet
Development

No branches or pull requests