Skip to content

Watch CSS module files for changes #17467

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Mar 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
- cli
- postcss
- workers
- webpack

# Exclude windows and macos from being built on feature branches
run-all:
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ 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))
- Ensure the `--theme(…)` function still resolves to the CSS variables even when legacy JS plugins are enabled
- Extract used CSS variables from `.css` files ([#17433](https://github.com/tailwindlabs/tailwindcss/pull/17433), [#17467](https://github.com/tailwindlabs/tailwindcss/pull/17467))

### Changed

Expand Down
1 change: 0 additions & 1 deletion crates/oxide/src/extractor/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use crate::cursor;
use crate::extractor::machine::Span;
use bstr::ByteSlice;
use candidate_machine::CandidateMachine;
use css_variable_machine::CssVariableMachine;
use machine::{Machine, MachineState};
Expand Down
70 changes: 25 additions & 45 deletions crates/oxide/src/scanner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ use fxhash::{FxHashMap, FxHashSet};
use ignore::{gitignore::GitignoreBuilder, WalkBuilder};
use rayon::prelude::*;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{self, Arc, Mutex};
use std::time::SystemTime;
use tracing::event;
Expand Down Expand Up @@ -265,18 +264,21 @@ 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;
match extension {
// Special handing for CSS files, we don't want to extract candidates from
// these files, but we do want to extract used CSS variables.
"css" => {
self.css_files.push(path.clone());
}
_ => {
self.changed_content.push(ChangedContent::File(
path.to_path_buf(),
extension.to_owned(),
));
}
}

self.extensions.insert(extension.to_owned());
self.changed_content.push(ChangedContent::File(
path.to_path_buf(),
extension.to_owned(),
));

self.files.push(path);
}
}
Expand Down Expand Up @@ -427,43 +429,21 @@ fn read_all_files(changed_content: Vec<ChangedContent>) -> Vec<Vec<u8>> {

#[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
extract(blobs, |mut extractor| {
extractor.extract_variables_from_css()
})
}

#[tracing::instrument(skip_all)]
fn parse_all_blobs(blobs: Vec<Vec<u8>>) -> Vec<String> {
extract(blobs, |mut extractor| extractor.extract())
}

#[tracing::instrument(skip_all)]
fn extract<H>(blobs: Vec<Vec<u8>>, handle: H) -> Vec<String>
where
H: Fn(Extractor) -> Vec<Extracted> + std::marker::Sync,
{
let mut result: Vec<_> = blobs
.par_iter()
.flat_map(|blob| blob.par_split(|x| *x == b'\n'))
Expand All @@ -472,7 +452,7 @@ fn parse_all_blobs(blobs: Vec<Vec<u8>>) -> Vec<String> {
return None;
}

let extracted = crate::extractor::Extractor::new(blob).extract();
let extracted = handle(crate::extractor::Extractor::new(blob));
if extracted.is_empty() {
return None;
}
Expand Down
2 changes: 1 addition & 1 deletion crates/oxide/tests/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ mod scanner {
("c.less", ""),
]);

assert_eq!(files, vec!["index.html"]);
assert_eq!(files, vec!["a.css", "index.html"]);
assert_eq!(globs, vec!["*"]);
assert_eq!(normalized_sources, vec!["**/*"]);
}
Expand Down
74 changes: 74 additions & 0 deletions integrations/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1468,6 +1468,80 @@ test(
},
)

test(
'changes to CSS files should pick up new CSS variables (if any)',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'unrelated.module.css': css`
.module {
color: var(--color-blue-500);
}
`,
'index.css': css`
@import 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
'index.html': html`<div class="flex"></div>`,
},
},
async ({ spawn, exec, fs, expect }) => {
// Generate the initial build so output CSS files exist on disk
await exec('pnpm tailwindcss --input ./index.css --output ./dist/out.css')

// NOTE: We are writing to an output CSS file which is not being ignored by
// `.gitignore` nor marked with `@source not`. This should not result in an
// infinite loop.
let process = await spawn(
'pnpm tailwindcss --input ./index.css --output ./dist/out.css --watch',
)
await process.onStderr((m) => m.includes('Done in'))

expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/out.css ---
:root, :host {
--color-blue-500: oklch(0.623 0.214 259.815);
}
.flex {
display: flex;
}
"
`)

await fs.write(
'unrelated.module.css',
css`
.module {
color: var(--color-blue-500);
background-color: var(--color-red-500);
}
`,
)
await process.onStderr((m) => m.includes('Done in'))

expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/out.css ---
:root, :host {
--color-red-500: oklch(0.637 0.237 25.331);
--color-blue-500: oklch(0.623 0.214 259.815);
}
.flex {
display: flex;
}
"
`)
},
)

function withBOM(text: string): string {
return '\uFEFF' + text
}
97 changes: 97 additions & 0 deletions integrations/postcss/next.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,100 @@ test(
])
},
)

test(
'changes to CSS files should pick up new CSS variables (if any)',
{
fs: {
'package.json': json`
{
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "^14"
},
"devDependencies": {
"@tailwindcss/postcss": "workspace:^",
"tailwindcss": "workspace:^"
}
}
`,
'postcss.config.mjs': js`
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
`,
'next.config.mjs': js`export default {}`,
'app/layout.js': js`
import './globals.css'

export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
`,
'app/page.js': js`
export default function Page() {
return <div className="flex"></div>
}
`,
'unrelated.module.css': css`
.module {
color: var(--color-blue-500);
}
`,
'app/globals.css': css`
@import 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
},
},
async ({ spawn, exec, fs, expect }) => {
// Generate the initial build so output CSS files exist on disk
await exec('pnpm next build')

// NOTE: We are writing to an output CSS file which is not being ignored by
// `.gitignore` nor marked with `@source not`. This should not result in an
// infinite loop.
let process = await spawn(`pnpm next dev`)

let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)/.exec(m)
if (match) url = match[1]
return Boolean(url)
})

await process.onStdout((m) => m.includes('Ready in'))

await retryAssertion(async () => {
let css = await fetchStyles(url)
expect(css).toContain(candidate`flex`)
expect(css).toContain('--color-blue-500:')
expect(css).not.toContain('--color-red-500:')
})

await fs.write(
'unrelated.module.css',
css`
.module {
color: var(--color-blue-500);
background-color: var(--color-red-500);
}
`,
)
await process.onStdout((m) => m.includes('Compiled in'))

await retryAssertion(async () => {
let css = await fetchStyles(url)
expect(css).toContain(candidate`flex`)
expect(css).toContain('--color-blue-500:')
expect(css).toContain('--color-red-500:')
})
},
)
2 changes: 1 addition & 1 deletion integrations/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ export function test(
return [
file,
// Drop license comment
content.replace(/[\s\n]*\/\*! tailwindcss .*? \*\/[\s\n]*/g, ''),
content.replace(/[\s\n]*\/\*![\s\S]*?\*\/[\s\n]*/g, ''),
]
}),
)
Expand Down
Loading