Ensure earlier @source is not accidentally ignored by later @source (#20335)

This PR fixes an issue where an `@source` pointing to a file in a nested
folder was not scanned when a later `@source` pointed to a file in a
parent folder.

E.g.:

```css
@source "./nested/index.html";
@source "./index.html";
```

When using `@source` pointing to a specific file, then we want to make
sure that we ignore _other_ files since they are not listed explicitly.
To ensure that these patterns don't read other files, we inject a `*`
ignore pattern before it. You can think of the above being expanded to:

```rs
Ignored { base: "/project/src/nested", pattern: "*" }
Pattern { base: "/project/src/nested", pattern: "/index.html" }
Ignored { base: "/project/src", pattern: "*" }
Pattern { base: "/project/src", pattern: "/index.html" }
```

The problem with this is that the `Ignored { base: "/project/src",
pattern: "*" }` pattern results in ignoring the `nested` folder as well.
This means that we never even walk into the `nested` folder, so the
earlier `@source "./nested/index.html"` never matches anything.

We could switch the order in user land, but that's going to be hard to
maintain (and order matters for undoing/redoing earlier rules, so we
can't re-order internally either). Instead, we can scope the ignore
pattern to the _current_ path only, and not deeply nested. In other
words, the pattern should become:

```diff
- *
+ /*
```

It's a very subtle difference, but the pattern from above will now
become:
```diff
  Ignored { base: "/project/src/nested", pattern: "*" }
  Pattern { base: "/project/src/nested", pattern: "/index.html" }
- Ignored { base: "/project/src", pattern: "*" }
+ Ignored { base: "/project/src", pattern: "/*" }
  Pattern { base: "/project/src", pattern: "/index.html" }
```

We already do this when an unrestricted root (e.g. `@source "./nested"`)
lives inside the base of a restricted pattern. This works because every
source base is also its own walk root, and a `/*` pattern only matches
direct children so it can't ignore anything when walking from the nested
root itself.

This PR extends that same check to restricted pattern bases: if another
`@source` pattern has its base nested inside the current base, we emit
`/*` instead of `*`.

Note that we only relax the pattern to `/*` when such a nested root
actually exists. Sibling folders that no `@source` points into (e.g. an
`ignore-me` folder next to `nested`) are still direct children, so they
still match `/*` and are never walked.

Fixes: #20333

## Test plan

1. Added a regression test with the reproduction setup
2. Added a similar test with another sibling folder that should still be
ignored
3. Tested it against the actual reproduction:

Before:
<img width="797" height="463" alt="image"
src="https://github.com/user-attachments/assets/893bb872-f5cf-4c5a-bfb5-2dc1043b5a39"
/>

After:
<img width="794" height="460" alt="image"
src="https://github.com/user-attachments/assets/a59aae45-68ad-4fe3-b21f-b568f535d24f"
/>

Notice that the `text-green-500` now appears as expected.
This commit is contained in:
Robin Malfait 2026-07-15 13:35:00 +02:00 • committed by GitHub
parent f861d5c5e6
commit 7811d74f37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 82 additions and 7 deletions

View file

@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Lazily load `@parcel/watcher` when using the `--watch` flag in `@tailwindcss/cli`, so one-off builds and `--watch --poll` work when `@parcel/watcher` can't be loaded ([#20325](https://github.com/tailwindlabs/tailwindcss/issues/20325))
- Use explicit platform fonts instead of `system-ui` and `ui-sans-serif` so CJK text respects the page's `lang` attribute on Windows ([#19767](https://github.com/tailwindlabs/tailwindcss/issues/19767), [#19768](https://github.com/tailwindlabs/tailwindcss/issues/19768))
- Prevent `@tailwindcss/upgrade` from rewriting ignored files when run from a subdirectory ([#20328](https://github.com/tailwindlabs/tailwindcss/issues/20328))
- Ensure earlier `@source` rules pointing to nested files are scanned when later `@source` rules point to files in parent folders ([#20335](https://github.com/tailwindlabs/tailwindcss/pull/20335))
## [4.3.2] - 2026-06-26

View file

@ -148,6 +148,16 @@ fn expand_restricted_patterns(sources: Vec<SourceEntry>) -> Vec<SourceEntry> {
})
.collect::<Vec<_>>();
// Bases of restricted patterns. Each of these becomes its own walk root with its own
// `*` + `!<pattern>` rules, so an ancestor base must not ignore them recursively.
let pattern_roots = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Pattern { base, .. } => Some(base.clone()),
_ => None,
})
.collect::<Vec<_>>();
let mut restricted_roots: FxHashSet<PathBuf> = FxHashSet::default();
let mut expanded = vec![];
@ -167,14 +177,17 @@ fn expand_restricted_patterns(sources: Vec<SourceEntry>) -> Vec<SourceEntry> {
}
// Ignore everything in the directory. We will later add the specific patterns we are
// interested in. When another source root is nested in this base, only ignore direct
// children so the nested source can still be walked from its own root.
// interested in.
if restricted_roots.insert(base.clone()) {
let pattern = if unrestricted_roots.iter().any(|root| root.starts_with(base)) {
"/*"
} else {
"*"
};
// When another source root is nested inside this base — an unrestricted root, or the
// base of another restricted pattern (which is walked from its own root with its own
// rules) — only ignore direct children so the nested root can still be walked.
let has_nested_root = unrestricted_roots.iter().any(|root| root.starts_with(base))
|| pattern_roots
.iter()
.any(|root| root != base && root.starts_with(base));
let pattern = if has_nested_root { "/*" } else { "*" };
expanded.push(SourceEntry::Ignored {
base: base.clone(),

View file

@ -1297,6 +1297,67 @@ mod scanner {
assert_eq!(files, vec!["src/bar.html", "src/foo.html"]);
}
// https://github.com/tailwindlabs/tailwindcss/issues/20333
#[test]
fn it_should_combine_nested_and_root_restricted_sources() {
// The restriction (`*`) added for the root-level file must not prevent walking into the
// `nested` folder that another explicit source points into. At the same time, relaxing
// that restriction to `/*` must not accidentally open up sibling folders (`ignore-me`)
// or files that no explicit source points at.
let paths_with_content = &[
("nested/component.html", "content-['nested/component.html']"),
("nested/ignore-me.html", "content-['nested/ignore-me.html']"),
("ignore-me/component.html", "content-['ignore-me']"),
("component-sources.classes.txt", "content-['classes.txt']"),
("ignore-me.txt", "content-['ignore-me.txt']"),
];
let ScanResult {
candidates, files, ..
} = scan_with_globs(
paths_with_content,
vec![
"@source './nested/component.html'",
"@source './component-sources.classes.txt'",
],
);
assert_eq!(
candidates,
vec![
"content-['classes.txt']",
"content-['nested/component.html']"
]
);
assert_eq!(
files,
vec!["component-sources.classes.txt", "nested/component.html"]
);
// Same setup, but with the root-level source declared first
let ScanResult {
candidates, files, ..
} = scan_with_globs(
paths_with_content,
vec![
"@source './component-sources.classes.txt'",
"@source './nested/component.html'",
],
);
assert_eq!(
candidates,
vec![
"content-['classes.txt']",
"content-['nested/component.html']"
]
);
assert_eq!(
files,
vec!["component-sources.classes.txt", "nested/component.html"]
);
}
#[test]
fn it_should_allow_later_ignores_to_override_restricted_sources() {
let ScanResult {