Handle CSS nesting natively#20124
Draft
RobinMalfait wants to merge 20 commits into
Draft
Conversation
bfc4058 to
d7aaac0
Compare
Use the `pretty(…)` helper for the AST related tests wherever we didn't do that yet.
This is temporary and on purpose. Once we implement nesting, we can see the impact on the tests. Once we are happy with that, we can add the optimizations from Lightning CSS again and if everything goes well, we should have barely any CSS changes in the final diff (at least related to CSS nesting).
Will be undone once Lightning CSS is enabled again
Will be re-enabled once Lightning CSS is enabled again
These tests don't really test AST specific things. They really test how `walk` works and what happens when you use certain `WalkAction` commands...
This part of the optimize AST step is covered automatically by the custom nesting handling.
If you are using `@variant` in your CSS without any rules, then we could potentially introduce invalid CSS. We start from a rule with a `&` selector, but if we didn't have a selector in the first place then we would introduce a selector which is incorrect. This fix tracks the nodes of the intermediate node if that's the case.
We were incorrectly mapping the source locations of rules to that of the used declaration which was wrong.
d7aaac0 to
9e82582
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR introduces a new feature where we will be handling the CSS nesting ourselves.
We currently still rely on Lightning CSS in most places. But there are situations where we don't use Lightning CSS out of the box:
@tailwindcss/browseror in Tailwind Play.We handle CSS nesting in a single pass over the AST by tracking some information as we go. It's not the most complex code, but there are some tricky parts to make this happen in an efficient way, especially for the few additional optimizations we handle.
While going over the AST, we will only emit CSS the moment we see declarations or comments. This also means that this has a fun side effect of removing CSS that ends up with empty nodes automatically.
This also allowed us to do some cleanup in
optimizeAstthat tried to do this as well, but now this will be handled by the code that handles nesting automatically. Which is preferred because the version inoptimizeAstmutated the AST.This also contains some optimizations where we merge adjacent at-rules (with the same name / params), and adjacent rules with the same selector, and get rid of declarations that are duplicated in a node.
To ensure that this implementation is correct, I also added an oracle implementation in the tests. This implementation does multiple passes over the AST, because it does each step one by one, with minimal code. Each step contains comments with examples to see what's happening in that step. We then test the optimized version against this.
While handling the nesting, we have to make sure that
&exists and if we replace it with a parent selector that we do use:is(…)semantics. This means that:Becomes:
We then also make sure that we optimize the selector by removing the unnecessary
:is(…)wrappers. Sometimes we flip parts of the selector around to further optimize. E.g::is(.foo, .bar):is(ul)cannot just become:is(.foo, .bar)ul, that would be invalid. So this becomesul:is(.foo, .bar)instead.If you look at the commits, the first thing we did is remove the optimization step from Lightning CSS in the tests. Then we enabled our CSS nesting handling code. This allows us to see the effect of the changes we are making. At the end, we re-enabled Lightning CSS.
For now, this PR will be a step that happens before Lightning CSS is executed, while still using Lightning CSS. But now this step will also always happen in places where we don't use Lightning CSS at all.
This should not result in any breaking changes. It could result in changed CSS output in environments where Lightning CSS isn't used. In environments where it is being used, then there could be some differences related to some selectors but they should result in the same behavior.
While testing things, I noticed that there are some missed opportunities for performance related to how we extract variables from declaration values. I want to tackle
optimizeAstin future PRs to make it simpler, more performant, and maybe even merge it with the CSS nesting handling.As part of testing this, I tested it against the tailwindcss.com codebase which contains a lot of CSS (807.67 KB, 18 174 AST nodes) because almost every utility is being used in examples.
The oracle implementation is rather slow:
But the final code is much faster (
<10ms):In contrast, Lightning CSS takes:
[ 37.48ms] Optimized by Lightning CSSOne thing to keep in mind here is that Lightning CSS does more things, such as normalizing values, handling vendor prefixes, CSS nesting, etc.
Some notes on how the algorithm works:
The basic idea
When you have CSS that looks this:
Then the AST looks like this:
When we walk this tree, and we encounter a
rule, then we will track the selector on a stack. When we are done walking over the rule, then we will pop the selector from the stack. This means that the top-most selector on the stack will always be the parent selector.The moment we encounter a
rule, and if a previous rule was seen, then we push theselectorof the rule onto the stack, but in a way that the&is already replaced by the selector.The simple version looks like this:
So far we aren't doing much yet, but the interesting part is when we encounter a
declaration(or acomment). The moment we see any of those, then will we emit a node with the information from theselectorStack.We then also track the last node's
nodeswe created such that we can push more declarations into it as a shortcut.The last important part is that whenever we see a new
rule, then we have to reset thatnodestracking variable such that we can create a fresh node the next time we see a declaration.For the
at-rules, something similar happens but they are tracked in a similar stack. The idea there is that we can then wrap thoseat-rulesaround thenewNodewe create. That way the at-rules naturally float to the top.I can keep going here, but I think if you're interested in this, then you could go over the commits in this PR, or you can look at the
ast.tsimplementation directly to see what's going on.Test plan