Skip to content

Latest commit

 

History

History
129 lines (100 loc) · 5.24 KB

extend-specificity.md

File metadata and controls

129 lines (100 loc) · 5.24 KB

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.