Skip to content

Commit 2af7c57

Browse files
authored
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" />
1 parent 3412a96 commit 2af7c57

File tree

5 files changed

+134
-3
lines changed

5 files changed

+134
-3
lines changed

CHANGELOG.md

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

3435
### Changed
3536

crates/oxide/src/extractor/mod.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::cursor;
22
use crate::extractor::machine::Span;
3+
use bstr::ByteSlice;
34
use candidate_machine::CandidateMachine;
45
use css_variable_machine::CssVariableMachine;
56
use machine::{Machine, MachineState};
@@ -139,6 +140,41 @@ impl<'a> Extractor<'a> {
139140

140141
extracted
141142
}
143+
144+
pub fn extract_variables_from_css(&mut self) -> Vec<Extracted<'a>> {
145+
let mut extracted = Vec::with_capacity(100);
146+
147+
let len = self.cursor.input.len();
148+
149+
let cursor = &mut self.cursor.clone();
150+
while cursor.pos < len {
151+
if cursor.curr.is_ascii_whitespace() {
152+
cursor.advance();
153+
continue;
154+
}
155+
156+
if let MachineState::Done(span) = self.css_variable_machine.next(cursor) {
157+
// We are only interested in variables that are used, not defined. Therefore we
158+
// need to ensure that the variable is prefixed with `var(`.
159+
if span.start < 4 {
160+
cursor.advance();
161+
continue;
162+
}
163+
164+
let slice_before = Span::new(span.start - 4, span.start - 1);
165+
if !slice_before.slice(self.cursor.input).starts_with(b"var(") {
166+
cursor.advance();
167+
continue;
168+
}
169+
170+
extracted.push(Extracted::CssVariable(span.slice(self.cursor.input)));
171+
}
172+
173+
cursor.advance();
174+
}
175+
176+
extracted
177+
}
142178
}
143179

144180
// Extract sub-candidates from a given range.

crates/oxide/src/scanner/fixtures/ignored-extensions.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
css
21
less
32
lock
43
sass

crates/oxide/src/scanner/mod.rs

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ pub struct Scanner {
8484
/// All found extensions
8585
extensions: FxHashSet<String>,
8686

87+
/// All CSS files we want to scan for CSS variable usage
88+
css_files: Vec<PathBuf>,
89+
8790
/// All files that we have to scan
8891
files: Vec<PathBuf>,
8992

@@ -212,11 +215,25 @@ impl Scanner {
212215
fn extract_candidates(&mut self) -> Vec<String> {
213216
let changed_content = self.changed_content.drain(..).collect::<Vec<_>>();
214217

215-
let candidates = parse_all_blobs(read_all_files(changed_content));
218+
// Extract all candidates from the changed content
219+
let mut new_candidates = parse_all_blobs(read_all_files(changed_content));
220+
221+
// Extract all CSS variables from the CSS files
222+
let css_files = self.css_files.drain(..).collect::<Vec<_>>();
223+
if !css_files.is_empty() {
224+
let css_variables = extract_css_variables(read_all_files(
225+
css_files
226+
.into_iter()
227+
.map(|file| ChangedContent::File(file, "css".into()))
228+
.collect(),
229+
));
230+
231+
new_candidates.extend(css_variables);
232+
}
216233

217234
// Only compute the new candidates and ignore the ones we already have. This is for
218235
// subsequent calls to prevent serializing the entire set of candidates every time.
219-
let mut new_candidates = candidates
236+
let mut new_candidates = new_candidates
220237
.into_par_iter()
221238
.filter(|candidate| !self.candidates.contains(candidate))
222239
.collect::<Vec<_>>();
@@ -248,6 +265,12 @@ impl Scanner {
248265
.and_then(|x| x.to_str())
249266
.unwrap_or_default(); // In case the file has no extension
250267

268+
// Special handing for CSS files to extract CSS variables
269+
if extension == "css" {
270+
self.css_files.push(path);
271+
continue;
272+
}
273+
251274
self.extensions.insert(extension.to_owned());
252275
self.changed_content.push(ChangedContent::File(
253276
path.to_path_buf(),
@@ -402,6 +425,43 @@ fn read_all_files(changed_content: Vec<ChangedContent>) -> Vec<Vec<u8>> {
402425
.collect()
403426
}
404427

428+
#[tracing::instrument(skip_all)]
429+
fn extract_css_variables(blobs: Vec<Vec<u8>>) -> Vec<String> {
430+
let mut result: Vec<_> = blobs
431+
.par_iter()
432+
.flat_map(|blob| blob.par_split(|x| *x == b'\n'))
433+
.filter_map(|blob| {
434+
if blob.is_empty() {
435+
return None;
436+
}
437+
438+
let extracted = crate::extractor::Extractor::new(blob).extract_variables_from_css();
439+
if extracted.is_empty() {
440+
return None;
441+
}
442+
443+
Some(FxHashSet::from_iter(extracted.into_iter().map(
444+
|x| match x {
445+
Extracted::CssVariable(bytes) => bytes,
446+
_ => &[],
447+
},
448+
)))
449+
})
450+
.reduce(Default::default, |mut a, b| {
451+
a.extend(b);
452+
a
453+
})
454+
.into_iter()
455+
.map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) })
456+
.collect();
457+
458+
// SAFETY: Unstable sort is faster and in this scenario it's also safe because we are
459+
// guaranteed to have unique candidates.
460+
result.par_sort_unstable();
461+
462+
result
463+
}
464+
405465
#[tracing::instrument(skip_all)]
406466
fn parse_all_blobs(blobs: Vec<Vec<u8>>) -> Vec<String> {
407467
let mut result: Vec<_> = blobs

crates/oxide/tests/scanner.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,4 +1735,39 @@ mod scanner {
17351735

17361736
assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]);
17371737
}
1738+
1739+
#[test]
1740+
fn test_extract_used_css_variables_from_css() {
1741+
let dir = tempdir().unwrap().into_path();
1742+
create_files_in(
1743+
&dir,
1744+
&[
1745+
(
1746+
"src/index.css",
1747+
r#"
1748+
@theme {
1749+
--color-red: #ff0000; /* Not used, so don't extract */
1750+
--color-green: #00ff00; /* Not used, so don't extract */
1751+
}
1752+
1753+
.button {
1754+
color: var(--color-red); /* Used, so extract */
1755+
}
1756+
"#,
1757+
),
1758+
("src/used-at-start.css", "var(--color-used-at-start)"),
1759+
// Here to verify that we don't crash when trying to find `var(` in front of the
1760+
// variable.
1761+
("src/defined-at-start.css", "--color-defined-at-start: red;"),
1762+
],
1763+
);
1764+
1765+
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
1766+
dir.clone(),
1767+
"@source './'",
1768+
)]);
1769+
let candidates = scanner.scan();
1770+
1771+
assert_eq!(candidates, vec!["--color-red", "--color-used-at-start"]);
1772+
}
17381773
}

0 commit comments

Comments
 (0)