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-1] Name, terminology, and nesting selector misnomers are footguns (solution proposed) #8329

Closed
zealvurte opened this issue Jan 18, 2023 · 11 comments

Comments

@zealvurte
Copy link

Problem

From #8310, I'm convinced this spec is currently a big footgun, due to the similarity to preprocessor nesting, and much of the feedback and expectations of it come from believing it can replace most use cases for preprocessor nesting, most notably as sugar for combining multiple selectors to avoid duplication.

As per that issue, it can not achieve this currently, and if it was changed to be able to, it would be a difficult enough challenge for implementers to make it performant, that they would have to delay it indefinitely until that difficultly can be overcome.

Assuming the current behaviour described (:is() is implicit on &, so that & refers to the parent selector's selected elements, not the parent selector) remains as is, I think this spec is going to (continue to) elicit a great deal of misunderstanding, with extra difficultly troubleshooting (by way of implicit forgiving selectors), and requiring a lot of learning resources to clarify things and list the many "gotchas" authors can encounter. It will also have to continue coexisting with the preprocessors tools, which right now seems problematic with it sharing much of the same terminology and syntax.

Proposed Solution

To avoid all this (while allowing for a future where preprocessor-style nesting may still be introduced to CSS), I'd like to propose changing the name, terminology, and nesting selector.

Change Naming & Terminology

I believe "nesting" is a misnomer for this spec's original intent and current state. Nesting is just a by-product of the syntax chosen and existing syntax for writing rules, there's no (to my knowledge) inheritance between the nesting happening so there's no hierarchal nesting, and it's obviously not manipulating ascendant and descendant context selectors, so referring to it by an inconsequential aspect of it seems misleading.

I think it would be more fitting to be referred to as "chaining" or "scoping". What the spec achieves could be better described as chaining the results (i.e. selected elements) of distinct selectors, to create arbitrary scopes within that chain for applying new rules. Making that clearer in name and terminology will help differentiate it, and avoid many of the misunderstandings and "gotchas" for authors.

I'm aware "chaining" is already sometimes used as a misnomer for the descendant combinator, but I think correcting that would be a bonus. I'm also aware [css-scoping] exists, but as I understand the current state of that spec, @scope is not used, and the name "scoping" doesn't quite fit it when it's not utilising :scope, defining scope as a concept and only focuses on the scoping/encapsulating for the shadow DOM. If there is conflict with [css-scoping], it would probably be a good time to look at the shared concepts and behaviours that could be resolved together.

Change Nesting Selector

With the above in mind, I believe :scope should be used instead of &, because:

  • :scope has an existing understanding of referring to the already selected elements when used in new selector within that scope
  • :scope will make it clear that the nested parent's selector will already be resolved to selected elements, therefore nesting is more like chaining selectors in a similar way as JS does, and not sugar for deduplicating multiple selectors
  • :scope is underutilised (being limited to CSS queries in JS), so prime real-estate for expanding upon (e.g., Element.querySelector('div :scope') and similar would also become possible under the same logic as a chaining selector that establishes a new scope.)
  • & reads too much like an "and" combinator, which would be a new and unnatural concept in CSS
  • & already has an understanding in preprocessor tools

Closing

I think these changes largely shrink the footgun potential, by differentiating it from preprocessor nesting almost entirely, and making the behaviour more explicit and intuitive for it's unique place in CSS. I would feel far more comfortable about the short and long term impact of the existing behaviour being used if that happened.

(Apologies if some of these topics have been covered in depth before, I've been away from web development for years, and from some searching of open and closed issues, I only found issues mentioning some of these things in different contexts, but not this same issue and proposal)

@zealvurte
Copy link
Author

After some further reading, I see the scoping concept is being updated as part of css-cascade-6, and even has issue 11 for determining the interaction with css-nesting-1.

Based on the direction of that, I more strongly believe "nesting" is not a useful term for the unique behaviour of this spec, especially when multiple at-rule blocks already allow nesting that will likely intermix with this one.

The newer expanded use of :scope with scoping-limits also seem to fit this concept and purpose better, and it shouldn't conflict unless there is a practical use-case (is there?) for needing to refer to an earlier :scope in a case where @scope and @nest might be nested inside one another (is this already considered for @scope nesting? I'd lean more towards having a :parent-scope(1) or :scope(-1) for such a purpose though). I can't see any special cases where :scope and & couldn't be used interchangeably as it stands (but of course I may have missed many things).

@denk0403
Copy link

denk0403 commented Jan 21, 2023

I made a similar point during the WebKit vote last month, arguing to change the name to "Selector Inheritance" instead. However, my suggestion for doing so was mainly to decouple the behavior from the syntax, back when everyone assumed the behavior would be like SCSS Nesting. I was also worried that the term "nesting" would wrongfully entail scoping of styles, which the WebKit explainer did not suggest.

However, now that the spec has agreed to use the nesting syntax and plans to scope nested styles to the selected elements of the parent selector, this feature is unmistakably "CSS Nesting". It just doesn't mean the same thing as "SCSS Nesting" because SCSS uses improper terminology and should have likely called their feature "Selector Inheritance".

That being said, since we're no longer getting SCSS-like nesting behavior, I agree that using & will just mislead developers and introduce a subtle but severe dichotomy between native CSS and preprocessor semantics. And since :scope already has the same semantics as :is(&), I agree the spec should probably use it instead. Personally, I find it to be more readable too.

And while we're suggesting syntax changes again, if we want to preserve cross-compatibility with SCSS nesting, I also still think the Option 4 syntax was best.

All things considered, I think CSS Nesting should look like this:

.a .b {
  // parent selector styles ...
} {
  .c :scope {
    // nested styles ...
  }
  .d {
    // nested styles ...
  }
}

Then in SCSS Nesting, although it's ugly, you can take advantage of both features:

.a .b {
  // parent selector styles ...
  .d {
    // nested styles ...
  }
} {
  .c :scope {
    // nested styles ...
  }
}

@mirisuzanne
Copy link
Contributor

It is not safe to reuse :scope as part of nesting. It's a selector that has existed in production for years, and has a defined meaning. Cascade-6 doesn't invent the selector, it only provides new ways to define scopes that the selector can reference. If we want to use :scope here, nesting would need to become a scoping mechanism, with all that entails. That's not a rout we want to go down. Scope is a much more narrowly focussed use-case, with more invasive implications.

This conversation has come up several times, because there clearly is overlap in use-cases between nesting and scope - but every time it's discussed, we find that they cannot simply be merged. The use-cases overlap, but are not identical. It's important that they work together, but that requires keeping the syntaxes distinct. Nesting is likely to happen in and around scoped rules, and the distinct meanings need to remain clear even when combined.

@zealvurte
Copy link
Author

[...] this feature is unmistakably "CSS Nesting".

Except nesting is already a syntax and terminology that exists in CSS, yet has no single behaviour attached to it. Naming this "nesting" would be defining behaviour for only one usage of nesting purely because it uses that syntax, while creating confusion where other specs use the same term and syntax already (on top of that from familiarity with the "Selector Inheritance" behaviour).

It is not safe to reuse :scope as part of nesting. It's a selector that has existed in production for years, and has a defined meaning. Cascade-6 doesn't invent the selector, it only provides new ways to define scopes that the selector can reference.

It would be safe to use for this purpose, as it still doesn't need redefining from selectors-4, and css-cascade-6's scoping mechanism already supports and allow most of what is needed.

The most significant change I foresee is that :scope is implicitly allowed pretty much everywhere right now, but currently it's behaviour in these new contexts are not defined or explored in examples except for scope-end, where under scoping limits it is defined that the selector it is used in and its placement establishes the relationship to the scoping root.

What happens if I put :scope in a scope-start selector of a nested @scope? What if it's not at the beginning? What about in a scoped selector inside @scope? I expect most of these to fail to ever match, but defining them in a similar way to scope-end would allow compatibility with css-nesting-1.

It would need to explicitly show that :scope can be used anywhere within a selector scope-start and @scope's body, and define what that means (especially within scoping limits). For the latter, this would override the implicit :is(:scope) prepended to the scoped selectors, allowing many types of scoping root relationships to be defined in a similar parsing to scope-end, but without it defining a scoping limit. For scope-start, it's the same parsing of relationship again, but establishes a new scope as it already does.

These are not foreign concepts to either spec.

If we want to use :scope here, nesting would need to become a scoping mechanism, with all that entails. That's not a rout we want to go down.

I'm saying it already acts like one, so it probably should be defined in the shared language of one, so why is this not a route to take?

[...] we find that they cannot simply be merged. The use-cases overlap, but are not identical. It's important that they work together, but that requires keeping the syntaxes distinct.

Can you point to something to support them not being mergeable and/or the syntaxes having to be distinct? The only conflict I see is deciding if the scoping proximity rules fit the use-cases covered by css-nesting-1 (which I think they do), and if it's decided to use strong proximity for scoped descendants by default (latest draft suggests it won't), does that remain the case, and if not, how to modify the behaviour (which may require a distinct syntax, or something that works across them all).

@tabatkins
Copy link
Member

This syntax defines how style rules are nested into other style rules; calling the spec "Nesting" works just fine, and there's not really anything that it's ambiguous with.

What happens if I put :scope in a scope-start selector of a nested @scope?

It matches one of the scoping elements from the nearest @scope (the outer one, since you're in the middle of defining the inner one).

What about in a scoped selector inside @scope?

It matches one of the scoping elements from the nearest @scope.

These are not foreign concepts to either spec.

Indeed they're not, :scope and & do essentially the same thing, and that's part of the issue - if we used :scope for both, and an author uses the two features together, they could only refer to one or the other. In particular, they wouldn't be able to use @scope (.one) { .two { :scope.three * {...}}} to style things where the scoping element has both .one and .three; their selector would instead be referring to a .two.three element underneath the scoping element, as the :scope refers to the intervening style rule rather than the scope rule.

This is probably a minor loss, all in all (and you can't refer to grandparent nesting rules in Nesting, anyway), but a @scope rule does generally represent a more semantically meaningful container than a nesting rule does, so the chance that you'll want to refer to it is higher. So losing the ability to refer to it if you're additionally nesting would sting a little bit more.

I'm saying it already acts like one, so it probably should be defined in the shared language of one, so why is this not a route to take?

Nesting does not act like a scoping container. It's very much normal to write nested rules that aren't remotely scoped to the outer rule, like .foo { :not(&) {...}}, not to mention using sibling combinators, etc. Nesting is a convenience feature for writing selectors that reuse other selectors, nothing more.

@zealvurte
Copy link
Author

This syntax defines how style rules are nested into other style rules; calling the spec "Nesting" works just fine, and there's not really anything that it's ambiguous with.

Except it's not really a style rule inside a style rule, is it? It's a shorthand for an at-rule, and the nesting this is defining doesn't apply to other specs using their version of nesting. It's a conflict to call them all nesting while defining only one of them as nesting that works in a unique way.

It matches one of the scoping elements from the nearest @scope (the outer one, since you're in the middle of defining the inner one).
It matches one of the scoping elements from the nearest @scope.

But with :scope allowed in any position? If so, it's already doing what I suggest, just not explicitly indicated as such. If not, then that's unnecessarily limiting :scope to the start a selector only.

Indeed they're not, :scope and & do essentially the same thing, and that's part of the issue - if we used :scope for both, and an author uses the two features together, they could only refer to one or the other. In particular, they wouldn't be able to use @scope (.one) { .two { :scope.three * {...}}} to style things where the scoping element has both .one and .three; their selector would instead be referring to a .two.three element underneath the scoping element, as the :scope refers to the intervening style rule rather than the scope rule.

Why would you need to use them together in that way? If the use of each is creating a new scope, you would just write and nest your selectors and scopes accordingly.

Unless I'm misunderstanding, as it currently stands, your example would be :is(.one) :is(.two) :is(.one).three *. In what I'm suggesting, it would become :is(.one) :is(.two).three *. Neither achieve what you want, but your example is a confusing way to attempt that, so I'm not entirely sure what you wanted to target. In my suggestion something like @scope (.one) { .two {...} :scope.three * {...}} would work (even @scope (.one,.three) { * {...}} seems sensible, but not part of the spec).

[...] but a @scope rule does generally represent a more semantically meaningful container than a nesting rule does, so the chance that you'll want to refer to it is higher.

Except if nesting was scoping, they have the same semantic meaning. I'd also argue being meaningful is both situational and not the important factor for referring to it later, as a unique scope is less likely to be reused than one that could match multiple times as in nesting (i.e. the same scope matching inside that scope).

Nesting does not act like a scoping container. It's very much normal to write nested rules that aren't remotely scoped to the outer rule, like .foo { :not(&) {...}}, not to mention using sibling combinators, etc.

How is that not scoped? A limited scope should not be seen as the only way to use :scope or create new scopes from existing scopes.

I said scoping limits (a behaviour not part of :scope itself) would need to be considered, but didn't elaborate on that as to how it might change. Currently scoping is done strictly as a scoping limit, so you can't end up with an element outside the outer-most scope. Nesting would not want that in some cases, as there can be a desire to create new scopes related to the outer scope but not necessarily inside the scope, as you said.

  • Does that need an entirely new spec, terminology and syntax? Not in my opinion.
  • Does it need a modified or different at-rule for when those cases when nested inside @scope and wanting to reach outside it? Perhaps (but still not @nest).
  • Could it be better written with nesting outside the limiting @scope anyway? Most likely.

@tabatkins
Copy link
Member

Except it's not really a style rule inside a style rule, is it? It's a shorthand for an at-rule

I have no idea what you mean. Only @scope is an at-rule; Nesting isn't.

But with :scope allowed in any position? If so, it's already doing what I suggest, just not explicitly indicated as such. If not, then that's unnecessarily limiting :scope to the start a selector only.

Yes, :scope allowed anywhere. I'm not sure what point you're making here, tho.

Why would you need to use them together in that way?

Why wouldn't you? It's a perfectly reasonable selector. @scope (.one) { .two { :scope.three * {...}}} is equivalent to `@scope (.one) { :scope.three .two * {...}

Except if nesting was scoping, they have the same semantic meaning.

Right, but what I'm saying is they don't have the same semantic meaning, so nesting isn't scoping. The different semantics correspond to different behaviors and different use-cases. Trying to collapse them loses a lot of valuable stuff.

How is that not scoped?

Scoping has a meaning. :not(&) isn't scoped by that meaning, nor by a more casual meaning - the elements being selected come from anywhere on the page, with no particular relationship to the elements from the parent rule besides being "not them".

@zealvurte
Copy link
Author

I have no idea what you mean. Only @scope is an at-rule; Nesting isn't.

It's @nest, no?

Yes, :scope allowed anywhere. I'm not sure what point you're making here, tho.

That it being allowed anywhere and everywhere would be needed for "nesting" and is generally limiting if not allowed.

If it already is as you say, it's not being indicated in that spec right now. That it seems to enforce root + descendants only for scopes (hard to really be sure, it's implied throughout, but only gets touched upon under the scoped descendant combinator), my reading is that scope-start and any selectors within @scope always effectively begin :is(:scope) and :is(:scope) (note the space for descendants) and that can't be modified by putting :scope in the selector at different positions (attempts to do so would seem unlikely to match anything and be on any use to anyone).

Impractical examples:

@scope (.inner) {
	.outer :scope.special {...}
}

Would be scope .inner, with selector .outer :scope.special instead of :is(:scope).outer :scope.special, :is(:scope) .outer :scope.special.

@scope (.inner) {
	@scope (.outer :scope.special) {
		...
	}
}

Would be outer scope .inner, with inner scope .outer :scope.special instead of :is(:scope).outer :scope.special, :is(:scope) .outer :scope.special.

(Whether or not the parent mechanism should modify the meaning of :scope so :is() and/or root + descendants are always automatically applied even when it's explicitly given, can be a separate discussion, but right now, I don't see that they do).

It's a perfectly reasonable selector. @scope (.one) { .two { :scope.three * {...}}} is equivalent to @scope (.one) { :scope.three .two * {...}

Not sure I'd call that a "perfectly reasonable" selector, and certainly that's why I see now how I read it wrong. There's obviously better ways to write it in the first place, and I still don't see anywhere in the spec that allows it to mean anything but scope .one and the selector :is(:scope):scope.three .two *, :is(:scope) :scope.three .two *, which isn't useful. I'm suggesting the selector would be :scope.three.two *, which seems to be what you want and believe it is already... Where am I missing the definition that allows that change of behaviour?

Scoping has a meaning. :not(&) isn't scoped by that meaning, nor by a more casual meaning - the elements being selected come from anywhere on the page, with no particular relationship to the elements from the parent rule besides being "not them".

I will concede I have not read most of selectors-4 in several years, and I see the language was updated to not allow anything but the former scope-filtered meaning since 2018. It used to have more permissive meanings and scope-relative allowed the meaning I'm giving here, so I guess I'm making an argument for that being allowed again.

@tabatkins
Copy link
Member

It's @nest, no?

No, that was an earlier version of the spec. There is no at-rule anymore in the current spec.

If it already is as you say, it's not being indicated in that spec right now. That it seems to enforce root + descendants only for scopes

The :scope pseudo-class can appear anywhere in the selector (of style rules inside of @scope). The elements matched by that selector have to be the scoping root or one of its descendants, due to being "scoped", but any other components of the selector can match anything in the tree. For example, @scope (div.foo) { :root.bar p {...}} will match p elements inside a div.foo element if the root element (which is definitely outside the scope) has a .bar class. There's no implicit prepending of :scope to anything; the selectors are evaluated against the whole page, and then we just filter the results according to the scope.

It used to have more permissive meanings and scope-relative allowed the meaning I'm giving here, so I guess I'm making an argument for that being allowed again.

It turned out that we didn't actually want the "scope-relative" meaning anywhere. querySelector() doesn't use it, @scope doesn't use it; being able to refer to element outside your scope is just too useful of an ability to throw away. Nesting doesn't use it either - it does something vaguely similar but still very distinct, allowing the selector to match anything on the page with no required relation to the parent selector (if you write your selector accordingly).

@zealvurte
Copy link
Author

No, that was an earlier version of the spec. There is no at-rule anymore in the current spec.

It may not exist anymore, but I guess I will always implicitly see the short-hand version is really that.

The :scope pseudo-class can appear anywhere in the selector (of style rules inside of @scope). The elements matched by that selector have to be the scoping root or one of its descendants, due to being "scoped", but any other components of the selector can match anything in the tree. For example, @scope (div.foo) { :root.bar p {...}} will match p elements inside a div.foo element if the root element (which is definitely outside the scope) has a .bar class. There's no implicit prepending of :scope to anything; the selectors are evaluated against the whole page, and then we just filter the results according to the scope.

Thanks, I understand what you're saying now. I still think that needs explaining more far more clearly in the spec, especially with examples of :scope being used in such a way. I can also see why that isn't going to be compatible in the way I described now, though not necessarily because this is not scoping as per the wider meaning point.

I do remain unclear about multiple uses of :scope, as do I with &. These both refer to the elements matched by a selector, not the selector, so should never work (afaik), but nesting at least seems to break that meaning by showing examples of desugaring them to the parent selector instead (just like selector inheritance, which it's not supposed to be). Is that outdate and/or a mistake?

querySelector() doesn't use it
What if it did use it, even if with a different selector than :scope or options that nesting could use? It's something I'd like to see. It would simplify several lines of code for filtering (right now it can't return the :scope element itself, for whatever reasoning that was thought to not be allowed, so this can't be done for only filtering on ancestors) and traversing the tree. I don't see any other spec making that usage possible.

[...] being able to refer to element outside your scope is just too useful of an ability to throw away.

Not sure what you mean here? Both allow referring to elements outside the scope, but match to an element outside the scope in what @scope is using.

Nesting doesn't use it either - it does something vaguely similar but still very distinct, allowing the selector to match anything on the page with no required relation to the parent selector (if you write your selector accordingly).

I don't see the no relation (including your :not(&) example, as that's still a relationship) idea, so maybe that's why I don't see the distinction your see here? "Relative selectors" seems to be the terminology being used, which to me is a relationship to the parent selector. Even in the case of "non-relative selectors" with a nested selector, they actually are relative selectors (in plain language), but with a different relationship to the parent selector (even if exclusionary). So could you elaborate for me (assuming the broader meaning of scoping)?

I still think it's wrong to call this spec nesting while other specs use the term with a more general meaning that doesn't need defining (clearly showing css-nesting-1 is the odd one out here). Yes the rules are nested, it's more direct than others due to not needing selectors in the body to achieve it, and the spec leads with those things, but there's then the other at-rules that lead to nesting of rules without having relevance to the nesting of this spec. The rest of it is not specifically about the nesting, but about a way of creating a context for relative selectors that allows this type of nesting to work. That last part should be the focus and source of the naming.

@tabatkins
Copy link
Member

Closing this issue; ultimately the only remaining disagreement is about the shortname, and the CSSWG is happy with the css-nesting shortname.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants