sass/accepted/extend-specificity.md
2023-08-22 21:10:27 +00:00

130 lines
5.2 KiB
Markdown

# Extend Specificity
It's valuable to be able to optimize away selectors generated by `@extend` if
they match subsets of the elements matched by other selectors in the same style
rule. However, optimizing *every* such selector can end up having unexpected
consequences when it changes the specificity with which the style rule applies
to a given element. This proposal lays out restrictions on the specificity of
selectors that result from an `@extend`.
First of all, let's define the function `extend(S, A, B)` to be the result of
taking a selector `S` and extending it by replacing all instances of `A` with
`A, B` and resolving the result a la `@extend`. Here are some uncontroversial
examples:
```
extend(a, a, b) = a, b
extend(a.foo, a, b) = a.foo, b.foo
extend(c, a, b) = c
```
## Specificity of the Base Selector
Note that so far, it's always the case that `extend(S, A, B)[0] = S`. However,
consider `extend(a.foo, .foo, a)`. One interpretation of this would give the
result as `a.foo, a`. However, `a` matches a strict superset of the elements
that `a.foo` matches, so another interpretation could give the result as just
`a`. `a` and `a.foo, a` are semantically identical **except** for specificity.
Let's define a new function to talk about this: `spec(S)` is the specificity of
a selector `S`. So `spec(a.foo) = 11`, while `spec(a) = 1`. The nature of CSS
means that differences in specificity can lead to practical differences in
styling, so to some degree we clearly need to consider specificity as part of
the semantics of the selectors we deal with. This is the broad point of this
issue.
Let's get back to the example of `extend(a.foo, .foo, a)`. The first selector in
the result, `extend(a.foo, .foo, a)[0]`, corresponds to the selector written by
the user with the goal of directly styling a set of elements. Allowing the
specificity of this selector to change because an `@extend` was added elsewhere
in the stylesheet is semantic change at a distance, which is clearly something
we shouldn't allow. Thus, it should be the case that
`extend(a.foo, .foo, a)[0] = a.foo` and in general that
`spec(extend(S, A, B)[0]) >= spec(S)`.
In most cases, the first generated selector should be identical to `S`. However,
this isn't possible when dealing with the `:not()` pseudo-selector. For example,
``` scss
:not(.foo) {...}
.bar {@extend .foo}
```
Because `:not` specifically declares selectors that the rule **doesn't** apply
to, extending those selectors will necessarily increase the specificity of the
base selector. The example above should compile to
``` css
:not(.foo):not(.bar) {...}
```
This new selector has higher specificity than the original. As such, we must
allow the generated selector to have higher specificity than the original in
some cases.
### First Law of Extend: `spec(extend(S, A, B)[0]) >= spec(S)`
This is not always the behavior in Sass, either in master or in stable; this is
clearly a bug that should be fixed.
## Specificity of Generated Selectors
Now that we've established what `spec(extend(S, A, B)[0])` should look like,
it's time to think about what `spec(extend(S, A, B)[1])` should look like as
well. In order to allow our users to reason about the styling of their page, the
specificity of the generated selectors should clearly be as consistent as
possible. In an ideal world, if `@extend` were supported natively in the
browser, the specificity would be equivalent to that of the original selector;
that is, `spec(extend(S, A, B)[1]) = spec(S)`. However, that's not always
possible:
```
extend(a, a, b.foo) = a, b.foo
spec(a) < spec(b.foo)
extend(a.foo, a.foo, b) = a.foo, b
spec(a.foo) > spec(b)
```
Since consistency is desirable, we might be tempted instead to say that
`spec(extend(S, A, B)[1]) = spec(B)`. But that's not always possible either:
```
extend(a.foo, a, b) = a.foo, b.foo
spec(b) < spec(b.foo)
```
There is one guarantee we can make, though:
`spec(extend(S, A, B)[1]) >= spec(B)`, since everything in `S` is either merged
with or added to `B`.
### Second Law of Extend: `spec(extend(S, A, B)[1]) >= spec(B)`
## Implications for Optimization
The ultimate goal of this discussion is, of course, that we want to be able to
perform certain optimizations on the generated selectors in order to reduce
output size, but we don't want these optimizations to break the guarantees we
offer our users. Which optimizations do the guarantees outlines above allow us,
and which do they forbid?
One optimization that we've been doing for a long time is
`extend(a.foo, .foo, a) = a`, as discussed above. This violates the first law,
since `a != a.foo`.
Another optimization added in [8f4869e][] is `extend(a, a, a.foo) = a`. This
violates the second law, since `spec(a) < spec(a.foo)`.
[8f4869e]: https://github.com/sass/ruby-sass/commit/8f4869e608e70d7f468bb463ebfe7a939d834e27
However, many of the optimizations added in [8f4869e][] do still work. For
example, `extend(.bar a, a, a.foo) = .bar a` works because
`spec(.bar a) = spec(a.foo)`.
## Conclusion
As long as we make the `@extend` optimizer specificity-aware, we can retain a
number of useful optimizations while still providing the same guarantees that
they have without any optimizations. That's my proposal: that we support all the
optimizations we can while still abiding by the two Laws of Extend outlined
above.