From 7811d74f37855bb36b5818d0db807566a572f712 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 15 Jul 2026 13:35:00 +0200 Subject: [PATCH] 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: image After: image Notice that the `text-green-500` now appears as expected. --- CHANGELOG.md | 1 + crates/oxide/src/scanner/sources.rs | 27 +++++++++---- crates/oxide/tests/scanner.rs | 61 +++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2b3eb09..01106b585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index 36ccf3b7e..4a8c6ddf5 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -148,6 +148,16 @@ fn expand_restricted_patterns(sources: Vec) -> Vec { }) .collect::>(); + // Bases of restricted patterns. Each of these becomes its own walk root with its own + // `*` + `!` 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::>(); + let mut restricted_roots: FxHashSet = FxHashSet::default(); let mut expanded = vec![]; @@ -167,14 +177,17 @@ fn expand_restricted_patterns(sources: Vec) -> Vec { } // 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(), diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 726ce5921..65248cd14 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -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 {