Skip to content

Conversation

@RobinMalfait
Copy link
Member

@RobinMalfait RobinMalfait commented Oct 26, 2024

This PR introduces an internal refactor where we introduce the AtRule CSS Node in our AST.

The motivation for this is that in a lot of places we need to differentiate between a Rule and an AtRule. We often do this with code that looks like this:

rule.selector[0] === '@' && rule.selector.startsWith('@media')

Another issue we have is that we often need to check for '@media ' including the space, because we don't want to match @mediafoobar if somebody has this in their CSS. Alternatively, if you CSS is minified then it could be that you have a rule that looks like @media(width>=100px), in this case we also have to check for @media(.

Here is a snippet of code that we have in our codebase:

// Find at-rules rules
if (node.kind === 'rule') {
  if (
    node.selector[0] === '@' &&
    (node.selector.startsWith('@media ') ||
      node.selector.startsWith('@media(') ||
      node.selector.startsWith('@custom-media ') ||
      node.selector.startsWith('@custom-media(') ||
      node.selector.startsWith('@container ') ||
      node.selector.startsWith('@container(') ||
      node.selector.startsWith('@supports ') ||
      node.selector.startsWith('@supports(')) &&
    node.selector.includes(THEME_FUNCTION_INVOCATION)
  ) {
    node.selector = substituteFunctionsInValue(node.selector, resolveThemeValue)
  }
}

Which will now be replaced with a much simpler version:

// Find at-rules rules
if (node.kind === 'at-rule') {
  if (
    (node.name === '@media' ||
      node.name === '@custom-media' ||
      node.name === '@container' ||
      node.name === '@supports') &&
    node.params.includes(THEME_FUNCTION_INVOCATION)
  ) {
    node.params = substituteFunctionsInValue(node.params, resolveThemeValue)
  }
}

Checking for all the cases from the first snippet is not the end of the world, but it is error prone. It's easy to miss a case.

A direct comparison is also faster than comparing via the startsWith(…) function.


Note: this is only a refactor without changing other code unless it was required to make the tests pass. The tests themselves are all passing and none of them changed (because the behavior should be the same).
The one exception is the tests where we check the parsed AST, which now includes at-rule nodes instead of rule nodes when we have an at-rule.

@RobinMalfait RobinMalfait force-pushed the chore/introduce-at-rule branch from 24fe915 to 23bbcf4 Compare October 27, 2024 00:09
@adamwathan
Copy link
Member

Nice! Curious to benchmark this change, expecting it to be basically identical but secretly hoping it's a tiny bit faster.

@RobinMalfait
Copy link
Member Author

@adamwathan haha that was my next thing on the list to try. I'm hoping for the same results you mentioned.

@RobinMalfait
Copy link
Member Author

Running some benchmarks on the Tailwind UI codebase.

Before (next):

 ✓ src/index.bench.ts (1) 667ms
     name          hz      min      max     mean      p75      p99     p995     p999     rme  samples
   · compile  54.5384  16.6713  22.1950  18.3357  18.7034  22.1950  22.1950  22.1950  ±2.45%       28

After (pr):

 ✓ src/index.bench.ts (1) 650ms
     name          hz      min      max     mean      p75      p99     p995     p999     rme  samples
   · compile  53.4548  16.5493  28.0242  18.7074  18.9284  28.0242  28.0242  28.0242  ±5.57%       27

It looks like this PR is a tiny bit slower right now. Thinking about it, it makes sense from a CSS parsing perspective because we are doing a bit more when we see at-rules. We also re-parse every time we see an at-rule in the variants used, I think this is where we can do some speedups.

Let me try some things, because it's easier to work with style rules and at rules separately, but not at the cost of perfomance.

@RobinMalfait RobinMalfait force-pushed the chore/introduce-at-rule branch 2 times, most recently from 4a8f3bc to 76b5e1f Compare October 27, 2024 11:25
@RobinMalfait
Copy link
Member Author

RobinMalfait commented Oct 27, 2024

Few improvements later, these are the results:

Before (next):

 ✓ src/index.bench.ts (1) 666ms
     name          hz      min      max     mean      p75      p99     p995     p999     rme  samples
   · compile  52.8106  16.1739  29.1533  18.9356  18.9055  29.1533  29.1533  29.1533  ±6.61%       27

After (pr):

 ✓ src/index.bench.ts (1) 661ms
     name          hz      min      max     mean      p75      p99     p995     p999     rme  samples
   · compile  52.8260  16.2722  29.1343  18.9301  19.7049  29.1343  29.1343  29.1343  ±6.34%       27

@RobinMalfait RobinMalfait force-pushed the chore/introduce-at-rule branch from 324afae to 49da19c Compare October 27, 2024 11:35
@RobinMalfait RobinMalfait marked this pull request as ready for review October 27, 2024 11:36
@RobinMalfait RobinMalfait requested a review from a team as a code owner October 27, 2024 11:36
Copy link
Member

@adamwathan adamwathan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I think this is a great improvement.

The only high level thing I'm half wondering about is whether we should include the @ sign as part of AtRule.name for greppability even if it's not necessary? Don't have a strong opinion on it but does feel like something we're losing with this change. Could easily be convinced not to worry about it.


const AT_SIGN = 0x40

export type StyleRule = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should just use Rule for this type and rule for the helper function instead of styleRule, as rules and at-rules are distinct concepts in CSS terminology already:

image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking the same, but @thecrypticace pointed me to the spec where they use Style rules and At rules. I think just Rule is very common because of PostCSS.

image

I'm fine with either. Biggest benefit for separating is that we have a rule() abstraction that returns a style rule via styleRule() or an at rule via atRule() right now depending on the @ being present.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the spec explicitly calls them style rules.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'm fine with style rule then if that's considered more technically correct 👍

@RobinMalfait
Copy link
Member Author

Hmm, I do like the grepability of looking for @media 🤔

Fun fact, during printing, I forgot to add an @ in some spots, so maybe this is not a bad change.

Any strong feelings about this @thecrypticace?

@thecrypticace
Copy link
Contributor

Hmm, I do like the grepability of looking for @media 🤔

Fun fact, during printing, I forgot to add an @ in some spots, so maybe this is not a bad change.

Any strong feelings about this @thecrypticace?

Nah, I say go for it.

@RobinMalfait RobinMalfait force-pushed the chore/introduce-at-rule branch from cdd1447 to 99c1d27 Compare October 28, 2024 23:34
This follows the CSS spec naming, which is useful if we have `StyleRule`
and `AtRule`.
This keeps the codebase in a similar state as before, but this just
creates one of the follow nodes: `AtRule | StyleRule`
Keeps the diff smaller. Might drop this commit before merging.
This keeps the diff smaller
We can use `rule(…)` here as well, and it will make the diff smaller.
However, that requires us to jump through an additional function where
we verify that we don't start with `@`.

We already know this, so let's skip the indirection.
This is a tiny optimization, but the moment we call `parseAtRule` we
know 2 things:
1. It starts with an `@` because it's an at-rule, therefore we can skip
   this character.
2. You need at least 4 characters (excluding `@`) because `@page` is the
   shortest valid at-rule.

This means that we can start looking past `@page`, which means that we
can start at character 6.

List of valid CSS at-rules (currently) ordered by length:

- `@page`
- `@layer`
- `@media`
- `@scope`
- `@import`
- `@charset`
- `@property`
- `@supports`
- `@container`
- `@font-face`
- `@keyframes`
- `@namespace`
- `@custom-media`
- `@position-try`
- `@color-profile`
- `@counter-style`
- `@starting-style`
- `@view-transition`
- `@font-feature-values`
- `@font-palette-values`
No need to create a new rule and mutate the original. We can just push
the new rule directly and share the original keyframesRule object.
In theory, people could write their own at-rules (e.g.: if you sandwich
`@tailwindcss/postcss` between 2 PostCSS plugins that handle custom
at-rules).

In that scenario, it is possible to have `@x` at-rules.
Changed my mind again. There are a lot of conditions necessary to use
shorter at-rules. This means that we can improve the parsing by using
the more common case of skipping 5 characters by default.

We can adjust this number if it's actually causing issues.
This allows us to just search for `@media` in the codebase and find
wherever we used the at-rules. This is purely a development QoL
improvement.
@adamwathan adamwathan force-pushed the chore/introduce-at-rule branch from 99c1d27 to cb5222b Compare October 28, 2024 23:58
Copy link
Member

@adamwathan adamwathan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Glad we decided to include the @ seeing the diff now, I think the code reads a lot better with it being part of the string 👍

@RobinMalfait RobinMalfait merged commit c439cdf into next Oct 29, 2024
1 check passed
@RobinMalfait RobinMalfait deleted the chore/introduce-at-rule branch October 29, 2024 00:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants