Extract used CSS variables from .css files (#17433)

This PR fixes an issue where CSS variables could be used in CSS modules,
but where never emitted in your final CSS.

Some backstory, when Tailwind CSS v4 came out, we _always_ emitted all
CSS variables whether they were used or not.

Later, we added an optimization where we only emit the CSS variables
that were actually used. The definition of "used" in this case is:

1. Used in your CSS  file(s) — (we check the final CSS AST for this)
2. Used _somewhere_ in any of your source files (e.g.: a JavaScript file
accessing a variable)

The issue this PR tries to solve is with the very first point. If you
are using CSS modules, then every CSS file is processed separately. This
is not a choice Tailwind CSS made, but how other build tooling works
(like Vite for example).

To prevent emitting all of Tailwind's Preflight reset and all utilities
per CSS file, you can use the `@reference` directive instead of
repeating `@import "tailwindcss";`. This is explained here:
https://tailwindcss.com/docs/compatibility#explicit-context-sharing

But now we are just _referencing_ them, not emitting them. And since the
CSS module is not connected in any way to the main `index.css` file that
contains the `@import "tailwindcss";` directive, we don't even see the
CSS variables while processing the `index.css` file. (or wherever your
main CSS file is)

This is where point 2 from above comes in. This is a situation where we
rely on the extractor to find the used CSS variables so we can
internally mark them as used.

To finally get to the point of this PR, the extractor only scans
`.html`, `.js`, ... files but not `.css` files. So all the CSS variables
used inside of CSS modules will not be generated.

This PR changes that behavior to also scan `.css` files. But _only_ for
CSS variables (not any other type of class candidate). This is
important, otherwise all your custom `@utility foo {}` definitions would
always mark `foo` as a used class and include it in the CSS which is not
always the case.

On top extracting CSS variables, we will also make sure that the CSS
variables we find are in usage positions (e.g.: `var(--color-red-500)`)
and not in definition positions (e.g.: `--color-red-500: #ff0000;`).
This is important because we only want to emit the variables that are
actually used in the final CSS output.

One future improvement not implemented here, is that technically we will
also extract CSS variables that might not be used if defined in a
`@utility`.

```css
@utility never-used {
  color: var(--color-red-500); /* --color-red-500 will be emitted, even if it might not be used */
}
```

Fixes: #16904
Fixes: #17429

# Test plan

1. Added a test where CSS variables are defined in `.css` files (and
ignored)
2. Added a test where CSS variables are used in `.css` files (and
included)

Testing on the reproduction defined in #16904, the `.module.css` file
contains a reference to `var(--color-hot-pink)`, but generating a build
shows that the variable definition is not available:

<img width="1630" alt="image"
src="https://github.com/user-attachments/assets/a0d5c37e-6813-4cd5-a677-6c356b5a73d4"
/>

When you run the build again with the changes from this PR, then we _do_
see the definition of the `--color-hot-pink` in the root CSS file:
<img width="2876" alt="image"
src="https://github.com/user-attachments/assets/beab7c11-a31b-4ea4-8235-4849a8e92859"
/>
This commit is contained in:
Robin Malfait 2025-03-28 17:53:15 +01:00 committed by GitHub
parent 3412a9623d
commit 2af7c57983
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 134 additions and 3 deletions

View File

@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix negated `content` rules in legacy JavaScript configuration ([#17255](https://github.com/tailwindlabs/tailwindcss/pull/17255))
- Extract special `@("@")md:…` syntax in Razor files ([#17427](https://github.com/tailwindlabs/tailwindcss/pull/17427))
- Disallow arbitrary values with top-level braces and semicolons as well as unbalanced parentheses and brackets ([#17361](https://github.com/tailwindlabs/tailwindcss/pull/17361))
- Extract used CSS variables from `.css` files ([#17433](https://github.com/tailwindlabs/tailwindcss/pull/17433))
### Changed

View File

@ -1,5 +1,6 @@
use crate::cursor;
use crate::extractor::machine::Span;
use bstr::ByteSlice;
use candidate_machine::CandidateMachine;
use css_variable_machine::CssVariableMachine;
use machine::{Machine, MachineState};
@ -139,6 +140,41 @@ impl<'a> Extractor<'a> {
extracted
}
pub fn extract_variables_from_css(&mut self) -> Vec<Extracted<'a>> {
let mut extracted = Vec::with_capacity(100);
let len = self.cursor.input.len();
let cursor = &mut self.cursor.clone();
while cursor.pos < len {
if cursor.curr.is_ascii_whitespace() {
cursor.advance();
continue;
}
if let MachineState::Done(span) = self.css_variable_machine.next(cursor) {
// We are only interested in variables that are used, not defined. Therefore we
// need to ensure that the variable is prefixed with `var(`.
if span.start < 4 {
cursor.advance();
continue;
}
let slice_before = Span::new(span.start - 4, span.start - 1);
if !slice_before.slice(self.cursor.input).starts_with(b"var(") {
cursor.advance();
continue;
}
extracted.push(Extracted::CssVariable(span.slice(self.cursor.input)));
}
cursor.advance();
}
extracted
}
}
// Extract sub-candidates from a given range.

View File

@ -1,4 +1,3 @@
css
less
lock
sass

View File

@ -84,6 +84,9 @@ pub struct Scanner {
/// All found extensions
extensions: FxHashSet<String>,
/// All CSS files we want to scan for CSS variable usage
css_files: Vec<PathBuf>,
/// All files that we have to scan
files: Vec<PathBuf>,
@ -212,11 +215,25 @@ impl Scanner {
fn extract_candidates(&mut self) -> Vec<String> {
let changed_content = self.changed_content.drain(..).collect::<Vec<_>>();
let candidates = parse_all_blobs(read_all_files(changed_content));
// Extract all candidates from the changed content
let mut new_candidates = parse_all_blobs(read_all_files(changed_content));
// Extract all CSS variables from the CSS files
let css_files = self.css_files.drain(..).collect::<Vec<_>>();
if !css_files.is_empty() {
let css_variables = extract_css_variables(read_all_files(
css_files
.into_iter()
.map(|file| ChangedContent::File(file, "css".into()))
.collect(),
));
new_candidates.extend(css_variables);
}
// Only compute the new candidates and ignore the ones we already have. This is for
// subsequent calls to prevent serializing the entire set of candidates every time.
let mut new_candidates = candidates
let mut new_candidates = new_candidates
.into_par_iter()
.filter(|candidate| !self.candidates.contains(candidate))
.collect::<Vec<_>>();
@ -248,6 +265,12 @@ impl Scanner {
.and_then(|x| x.to_str())
.unwrap_or_default(); // In case the file has no extension
// Special handing for CSS files to extract CSS variables
if extension == "css" {
self.css_files.push(path);
continue;
}
self.extensions.insert(extension.to_owned());
self.changed_content.push(ChangedContent::File(
path.to_path_buf(),
@ -402,6 +425,43 @@ fn read_all_files(changed_content: Vec<ChangedContent>) -> Vec<Vec<u8>> {
.collect()
}
#[tracing::instrument(skip_all)]
fn extract_css_variables(blobs: Vec<Vec<u8>>) -> Vec<String> {
let mut result: Vec<_> = blobs
.par_iter()
.flat_map(|blob| blob.par_split(|x| *x == b'\n'))
.filter_map(|blob| {
if blob.is_empty() {
return None;
}
let extracted = crate::extractor::Extractor::new(blob).extract_variables_from_css();
if extracted.is_empty() {
return None;
}
Some(FxHashSet::from_iter(extracted.into_iter().map(
|x| match x {
Extracted::CssVariable(bytes) => bytes,
_ => &[],
},
)))
})
.reduce(Default::default, |mut a, b| {
a.extend(b);
a
})
.into_iter()
.map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) })
.collect();
// SAFETY: Unstable sort is faster and in this scenario it's also safe because we are
// guaranteed to have unique candidates.
result.par_sort_unstable();
result
}
#[tracing::instrument(skip_all)]
fn parse_all_blobs(blobs: Vec<Vec<u8>>) -> Vec<String> {
let mut result: Vec<_> = blobs

View File

@ -1735,4 +1735,39 @@ mod scanner {
assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]);
}
#[test]
fn test_extract_used_css_variables_from_css() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
(
"src/index.css",
r#"
@theme {
--color-red: #ff0000; /* Not used, so don't extract */
--color-green: #00ff00; /* Not used, so don't extract */
}
.button {
color: var(--color-red); /* Used, so extract */
}
"#,
),
("src/used-at-start.css", "var(--color-used-at-start)"),
// Here to verify that we don't crash when trying to find `var(` in front of the
// variable.
("src/defined-at-start.css", "--color-defined-at-start: red;"),
],
);
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
dir.clone(),
"@source './'",
)]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["--color-red", "--color-used-at-start"]);
}
}