5.2 KiB
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,
: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
: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)
.
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.