Merge branch 'tailwindlabs:main' into riscv64

This commit is contained in:
Bruno Verachten 2026-06-09 22:50:22 +02:00 • committed by GitHub
commit 307ee67114
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 863 additions and 229 deletions

View file

@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure class candidates are extracted from Twig `addClass(…)` and `removeClass(…)` calls ([#20198](https://github.com/tailwindlabs/tailwindcss/pull/20198))
- Don't crash in the Ruby or Vue preprocessors when scanning files containing invalid UTF-8 bytes ([#19588](https://github.com/tailwindlabs/tailwindcss/pull/19588))
- Allow `@variant` to be used inside `addBase` ([#19480](https://github.com/tailwindlabs/tailwindcss/pull/19480))
- Ensure `@source` globs with symlinks are preserved ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203))
- Ensure later `@source` rules can re-include files excluded by earlier `@source not` rules ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203))
- Upgrade: don't migrate empty class rules to invalid `@utility` rules ([#20205](https://github.com/tailwindlabs/tailwindcss/pull/20205))
- Ensure transitions between `inset-shadow-none` and other inset shadows work correctly ([#20208](https://github.com/tailwindlabs/tailwindcss/pull/20208))
### Changed

View file

@ -17,7 +17,6 @@ use fxhash::{FxHashMap, FxHashSet};
use ignore::{gitignore::GitignoreBuilder, WalkBuilder};
use init_tracing::{init_tracing, SHOULD_TRACE};
use rayon::prelude::*;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
@ -637,7 +636,16 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default();
let mut first_root: Option<&PathBuf> = None;
let mut ignores: BTreeMap<&PathBuf, BTreeSet<String>> = Default::default();
let mut ignores: Vec<(&PathBuf, Vec<String>)> = Default::default();
let mut emit = |base, pattern| match ignores.last_mut() {
Some((prev_base, patterns)) if *prev_base == base => {
patterns.push(pattern);
}
_ => {
ignores.push((base, vec![pattern]));
}
};
for source in sources.iter() {
match source {
@ -649,7 +657,7 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
}
}
SourceEntry::Pattern { base, pattern } => {
let mut pattern = pattern.to_string();
let pattern = pattern.to_owned();
if first_root.is_none() {
first_root = Some(base);
@ -658,35 +666,19 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
}
if !pattern.contains("**") {
// Ensure that the pattern is pinned to the base path.
if !pattern.starts_with("/") {
pattern = format!("/{pattern}");
}
// Specific patterns should take precedence even over git-ignored files:
ignores
.entry(base)
.or_default()
.insert(format!("!{}", pattern));
emit(base, format!("!{}", pattern));
} else {
// Assumption: the pattern we receive will already be brace expanded. So
// `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`.
if let Some(extension) = Path::new(&pattern).extension() {
// Extend auto source detection to include the extension
ignores
.entry(base)
.or_default()
.insert(format!("!*.{}", extension.to_string_lossy()));
emit(base, format!("!*.{}", extension.to_string_lossy()));
}
}
}
SourceEntry::Ignored { base, pattern } => {
let mut pattern = pattern.to_string();
// Ensure that the pattern is pinned to the base path.
if !pattern.starts_with("/") {
pattern = format!("/{pattern}");
}
ignores.entry(base).or_default().insert(pattern);
emit(base, pattern.to_owned());
}
SourceEntry::External { base } => {
if first_root.is_none() {
@ -696,16 +688,10 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
}
// External sources should take precedence even over git-ignored files:
ignores
.entry(base)
.or_default()
.insert(format!("!{}", "/**/*"));
emit(base, format!("!{}", "/**/*"));
// External sources should still disallow binary extensions:
ignores
.entry(base)
.or_default()
.insert(BINARY_EXTENSIONS_GLOB.clone());
emit(base, BINARY_EXTENSIONS_GLOB.clone());
}
}
}

View file

@ -1,7 +1,6 @@
use crate::glob::split_pattern;
use crate::GlobEntry;
use bexpand::Expression;
use std::path::PathBuf;
use std::path::{Component, Path, PathBuf};
use tracing::{event, Level};
use super::auto_source_detection::IGNORED_CONTENT_DIRS;
@ -102,72 +101,215 @@ impl PublicSourceEntry {
/// resolved path.
pub fn optimize(&mut self) {
// Resolve base path immediately
let Ok(base) = dunce::canonicalize(&self.base) else {
let Ok(mut base) = dunce::canonicalize(&self.base) else {
event!(Level::ERROR, "Failed to resolve base: {:?}", self.base);
return;
};
self.base = base.to_string_lossy().to_string();
// No dynamic part, figure out if we are dealing with a file or a directory.
if !self.pattern.contains('*') {
let combined_path = if self.pattern.starts_with("/") {
PathBuf::from(&self.pattern)
} else {
PathBuf::from(&self.base).join(&self.pattern)
};
match dunce::canonicalize(combined_path) {
Ok(resolved_path) if resolved_path.is_dir() => {
self.base = resolved_path.to_string_lossy().to_string();
self.pattern = "**/*".to_owned();
}
Ok(resolved_path) if resolved_path.is_file() => {
self.base = resolved_path
.parent()
.unwrap()
.to_string_lossy()
.to_string();
// Ensure leading slash, otherwise it will match against all files in all folders/
self.pattern =
format!("/{}", resolved_path.file_name().unwrap().to_string_lossy());
}
_ => {}
}
return;
let mut new_pattern = PathBuf::new();
enum ComponentStage {
Base,
Pattern,
}
let mut stage = ComponentStage::Base;
// Contains dynamic part
let (static_part, dynamic_part) = split_pattern(&self.pattern);
let mut components = Path::new(&self.pattern).components().peekable();
while let Some(component) = components.next() {
match stage {
ComponentStage::Base => {
match component {
// Ignore the current dir, e.g. `.`
Component::CurDir => {}
let base: PathBuf = self.base.clone().into();
let base = match static_part {
Some(static_part) => {
// TODO: If the base does not exist on disk, try removing the last slash and try
// again.
match dunce::canonicalize(base.join(static_part)) {
Ok(base) => base,
Err(err) => {
event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err);
return;
// Go up a directory, e.g. `..`
Component::ParentDir => {
base.pop();
}
// Once we hit a component that contains a wildcard character, then we
// can't change the base anymore and we must move to the pattern part.
Component::Normal(part) if part.to_string_lossy().contains("*") => {
new_pattern.push(component);
stage = ComponentStage::Pattern;
}
// File or folder, but not the last component
Component::Normal(part) if components.peek().is_some() => {
base.push(part);
}
// Last file or folder. If it's a folder, we move it to the base,
// otherwise we move it to the pattern.
Component::Normal(part) => {
let full_path = base.join(part);
if full_path.is_dir() {
base.push(part);
} else {
new_pattern.push(part);
}
}
// When we're dealing with an absolute path, then we have to bypass the
// `base` entirely.
Component::Prefix(_) => {
base.clear();
base.push(component);
}
Component::RootDir => {
#[cfg(not(windows))]
base.clear();
base.push(component);
}
}
}
}
None => base,
};
let pattern = match dynamic_part {
Some(dynamic_part) => dynamic_part,
None => {
if base.is_dir() {
"**/*".to_owned()
} else {
"".to_owned()
ComponentStage::Pattern => {
new_pattern.push(component);
}
}
};
}
self.base = base.to_string_lossy().to_string();
self.pattern = pattern;
self.pattern = path_to_posix_string(&new_pattern);
// Ensure we have `**/*` when the base is a folder and we don't have a pattern at all
if self.pattern == "" {
self.pattern = "/**/*".to_owned();
}
// Ensure that the pattern is pinned to the base path.
else if !self.pattern.starts_with("/") {
self.pattern = format!("/{}", self.pattern);
}
}
}
fn path_to_posix_string(path: &Path) -> String {
let mut parts = Vec::new();
let mut is_rooted = false;
for component in path.components() {
match component {
Component::Prefix(prefix) => {
parts.push(prefix.as_os_str().to_string_lossy().to_string());
}
Component::RootDir => {
is_rooted = true;
if parts.is_empty() {
parts.push(String::new());
}
}
Component::CurDir => {
parts.push(".".to_string());
}
Component::ParentDir => {
parts.push("..".to_string());
}
Component::Normal(part) => {
parts.push(part.to_string_lossy().to_string());
}
}
}
let result = parts.join("/");
if result.is_empty() && is_rooted {
"/".to_string()
} else {
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::tempdir;
#[test]
fn path_to_posix_string_serializes_relative_paths() {
let path = PathBuf::from("src").join("**").join("*.html");
assert_eq!(path_to_posix_string(&path), "src/**/*.html");
}
#[test]
fn path_to_posix_string_serializes_rooted_paths() {
let path = PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
.join("src")
.join("**")
.join("*.html");
assert_eq!(path_to_posix_string(&path), "/src/**/*.html");
}
#[test]
fn path_to_posix_string_serializes_empty_paths() {
assert_eq!(path_to_posix_string(&PathBuf::new()), "");
}
#[test]
fn optimize_hoists_static_directories_and_keeps_files_in_the_pattern() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src").join("examples")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/examples/index.html".to_string(),
negated: false,
};
source.optimize();
assert_eq!(
source.base,
dunce::canonicalize(dir.path().join("src").join("examples"))
.unwrap()
.to_string_lossy()
);
assert_eq!(source.pattern, "/index.html");
}
#[test]
fn optimize_hoists_folder_patterns() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src").join("examples")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/examples".to_string(),
negated: false,
};
source.optimize();
assert_eq!(
source.base,
dunce::canonicalize(dir.path().join("src").join("examples"))
.unwrap()
.to_string_lossy()
);
assert_eq!(source.pattern, "/**/*");
}
#[test]
fn optimize_keeps_wildcards_in_the_pattern() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/**/*.html".to_string(),
negated: false,
};
source.optimize();
assert_eq!(
source.base,
dunce::canonicalize(dir.path().join("src"))
.unwrap()
.to_string_lossy()
);
assert_eq!(source.pattern, "/**/*.html");
}
}

View file

@ -20,6 +20,16 @@ mod scanner {
result
}
fn symlink_file<P: AsRef<Path>, Q: AsRef<Path>>(original: P, link: Q) -> std::io::Result<()> {
#[cfg(not(windows))]
let result = std::os::unix::fs::symlink(original, link);
#[cfg(windows)]
let result = std::os::windows::fs::symlink_file(original, link);
result
}
fn public_source_entry_from_pattern(dir: PathBuf, pattern: &str) -> PublicSourceEntry {
let mut parts = pattern.split_whitespace();
let _ = parts.next().unwrap_or_default();
@ -546,6 +556,36 @@ mod scanner {
assert_eq!(normalized_sources, vec!["**/*", "foo/bar/**/*"]);
}
#[test]
fn it_should_preserve_source_order_when_referencing_a_sibling_project() {
// Create a temporary working directory
let dir = tempdir().unwrap().into_path();
// Create files
create_files_in(
&dir,
&[
("project-a/src/index.css", ""),
("project-b/keep/keep.html", "content-['GOOD-1']"),
("project-b/ignored/ignored.html", "content-['BAD-1']"),
("project-b/ignored/except.html", "content-['GOOD-2']"),
],
);
let base = dir.join("project-a/src");
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(base.clone(), "@source '../../project-b'"),
public_source_entry_from_pattern(base.clone(), "@source not '../../project-b/ignored'"),
public_source_entry_from_pattern(
base.clone(),
"@source '../../project-b/ignored/except.html'",
),
]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['GOOD-1']", "content-['GOOD-2']"]);
}
#[test]
fn it_should_scan_files_without_extensions() {
// These look like folders, but they are files
@ -1761,6 +1801,88 @@ mod scanner {
assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]);
}
#[test]
fn test_symlinked_sources_within_the_scanned_tree_can_be_ignored() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
("src/keep.html", "content-['src/keep.html']"),
(
"actual-dir/ignore.html",
"content-['actual-dir/ignore.html']",
),
("actual-file.html", "content-['actual-file.html']"),
],
);
let _ = symlink(dir.join("actual-dir"), dir.join("linked-dir"));
let _ = symlink_file(dir.join("actual-file.html"), dir.join("linked-file.html"));
let base = dir.join("src");
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(base.clone(), "@source '../**/*'"),
public_source_entry_from_pattern(base.clone(), "@source not '../actual-dir'"),
public_source_entry_from_pattern(base.clone(), "@source not '../linked-dir'"),
public_source_entry_from_pattern(base.clone(), "@source not '../actual-file.html'"),
public_source_entry_from_pattern(base.clone(), "@source not '../linked-file.html'"),
]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['src/keep.html']"]);
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(base.clone(), "@source '../**/*'"),
public_source_entry_from_pattern(base.clone(), "@source not '../actual-dir/**/*.html'"),
public_source_entry_from_pattern(base.clone(), "@source not '../linked-dir/**/*.html'"),
public_source_entry_from_pattern(base.clone(), "@source not '../actual-file.html'"),
public_source_entry_from_pattern(base.clone(), "@source not '../linked-file.html'"),
]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['src/keep.html']"]);
}
#[test]
fn test_symlinked_sources_outside_the_scanned_tree_can_be_ignored() {
let dir = tempdir().unwrap().into_path();
create_files_in(
&dir,
&[
(
"actual-dir/ignore.html",
"content-['actual-dir/ignore.html']",
),
("project/keep.html", "content-['project/keep.html']"),
],
);
let _ = symlink(dir.join("actual-dir"), dir.join("project/linked-dir"));
let base = dir.join("project");
let mut scanner = Scanner::new(vec![public_source_entry_from_pattern(
base.clone(),
"@source '**/*'",
)]);
let candidates = scanner.scan();
assert_eq!(
candidates,
vec![
"content-['actual-dir/ignore.html']",
"content-['project/keep.html']",
]
);
let mut scanner = Scanner::new(vec![
public_source_entry_from_pattern(base.clone(), "@source '**/*'"),
public_source_entry_from_pattern(base.clone(), "@source not 'linked-dir'"),
]);
let candidates = scanner.scan();
assert_eq!(candidates, vec!["content-['project/keep.html']"]);
}
#[test]
fn test_extract_used_css_variables_from_css() {
let dir = tempdir().unwrap().into_path();

View file

@ -1938,7 +1938,7 @@ test(
`,
},
},
async ({ fs, exec, spawn, root, expect }) => {
async ({ fs, exec, root, expect }) => {
await exec('pnpm tailwindcss --input ./index.css --output dist/out.css', { cwd: root })
let content = await fs.dumpFiles('./dist/*.css')
@ -1949,6 +1949,201 @@ test(
},
)
test(
'@source order is important (referencing sibling project)',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'project-a/src/index.css': css`
@import 'tailwindcss/utilities' source(none);
@source '../../project-b';
@source not '../../project-b/ignored';
@source '../../project-b/ignored/except.html';
`,
'project-b/keep/keep.html': html`<div class="content-['GOOD-1']"></div>`,
'project-b/ignored/ignored.html': html`<div class="content-['BAD-1']"></div>`,
'project-b/ignored/except.html': html`<div class="content-['GOOD-2']"></div>`,
},
},
async ({ fs, root, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', {
cwd: path.join(root, 'project-a'),
})
expect(await fs.dumpFiles('./project-a/dist/*.css')).toMatchInlineSnapshot(`
"
--- ./project-a/dist/out.css ---
@layer properties;
.content-\\[\\'GOOD-1\\'\\] {
--tw-content: 'GOOD-1';
content: var(--tw-content);
}
.content-\\[\\'GOOD-2\\'\\] {
--tw-content: 'GOOD-2';
content: var(--tw-content);
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
"
`)
},
)
test(
'@source works with symlinks (referencing sibling project)',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'project-a/src/index.css': css`
@import 'tailwindcss/utilities' source(none);
/* Same as the previous test, but using symlinks instead */
@source '../../project-b';
@source not '../../project-b/ignored';
@source '../../project-b/ignored/except.html';
`,
'project-b/keep/keep.html': html`<div class="content-['GOOD-1']"></div>`,
'project-c/ignored/ignored.html': html`<div class="content-['BAD-1']"></div>`,
'project-c/ignored/except.html': html`<div class="content-['GOOD-2']"></div>`,
// Symlink the ignored folder to another project
'project-b/ignored': 'symlink:../../project-c/ignored/',
},
},
async ({ fs, root, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', {
cwd: path.join(root, 'project-a'),
})
expect(await fs.dumpFiles('./project-a/dist/*.css')).toMatchInlineSnapshot(`
"
--- ./project-a/dist/out.css ---
@layer properties;
.content-\\[\\'GOOD-1\\'\\] {
--tw-content: 'GOOD-1';
content: var(--tw-content);
}
.content-\\[\\'GOOD-2\\'\\] {
--tw-content: 'GOOD-2';
content: var(--tw-content);
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
"
`)
},
)
test(
'@source works with symlinks (referencing folder in current folder)',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'src/index.css': css`
@import 'tailwindcss/utilities' source(none);
/* Same as the previous test, but using symlinks instead */
@source '../lib';
@source not '../lib/ignored';
@source '../lib/ignored/except.html';
`,
'lib/keep/keep.html': html`<div class="content-['GOOD-1']"></div>`,
'vendor/ignored/ignored.html': html`<div class="content-['BAD-1']"></div>`,
'vendor/ignored/except.html': html`<div class="content-['GOOD-2']"></div>`,
// Symlink the ignored folder to another folder
'lib/ignored': 'symlink:../../vendor/ignored/',
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/out.css ---
@layer properties;
.content-\\[\\'GOOD-1\\'\\] {
--tw-content: 'GOOD-1';
content: var(--tw-content);
}
.content-\\[\\'GOOD-2\\'\\] {
--tw-content: 'GOOD-2';
content: var(--tw-content);
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
"
`)
},
)
test(
'auto source detection disabled',
{

View file

@ -49,6 +49,7 @@ interface TestContext {
parseSourceMap(opts: string | SourceMapOptions): SourceMap
fs: {
write(filePath: string, content: string, encoding?: BufferEncoding): Promise<void>
symlink(dst: string, src: string): Promise<void>
create(filePaths: string[]): Promise<void>
read(filePath: string): Promise<string>
delete(filePath: string): Promise<void>
@ -294,11 +295,7 @@ export function test(
}
},
fs: {
async write(
filename: string,
content: string | Uint8Array,
encoding: BufferEncoding = 'utf8',
): Promise<void> {
async write(filename, content, encoding = 'utf8') {
let full = path.join(root, filename)
let dir = path.dirname(full)
await fs.mkdir(dir, { recursive: true })
@ -319,7 +316,19 @@ export function test(
await fs.writeFile(full, content, encoding)
},
async create(filenames: string[]): Promise<void> {
async symlink(target, src) {
let targetAbsolute = path.join(root, target)
let targetParent = path.dirname(targetAbsolute)
await fs.mkdir(targetParent, { recursive: true })
let srcAbsolute = path.join(root, src)
let srcParent = path.dirname(srcAbsolute)
await fs.mkdir(srcParent, { recursive: true })
await fs.symlink(targetAbsolute, srcAbsolute)
},
async create(filenames) {
for (let filename of filenames) {
let full = path.join(root, filename)
@ -329,11 +338,11 @@ export function test(
}
},
async delete(filename: string): Promise<void> {
async delete(filename) {
await fs.unlink(path.join(root, filename))
},
async read(filePath: string) {
async read(filePath) {
let content = await fs.readFile(path.resolve(root, filePath), 'utf8')
// Ensure that files read on Windows have \r\n line endings removed
@ -343,7 +352,7 @@ export function test(
return content
},
async glob(pattern: string) {
async glob(pattern) {
let files = await fastGlob(pattern, { cwd: root })
return Promise.all(
files.map(async (file) => {
@ -356,7 +365,7 @@ export function test(
}),
)
},
async dumpFiles(pattern: string) {
async dumpFiles(pattern) {
let files = await context.fs.glob(pattern)
return `\n${files
.slice()
@ -416,7 +425,17 @@ export function test(
`
for (let [filename, content] of Object.entries(config.fs)) {
await context.fs.write(filename, content)
if (content.toString().startsWith('symlink:')) {
// The symlink path is relative to the target destination's path
let target = path.join(
filename,
content.toString().slice('symlink:'.length), // Relative path
)
await context.fs.symlink(target, filename)
} else {
await context.fs.write(filename, content)
}
}
let shouldInstallDependencies = config.installDependencies ?? true

View file

@ -39,7 +39,7 @@ async function migrate(
.use(sortBuckets())
.use(formatNodes())
.process(stylesheet.root!, { from: expect.getState().testPath })
.then((result) => result.css)
.then((result) => `\n${result.css.trim()}\n`)
}
it('should migrate simple `@layer utilities` to `@utility`', async () => {
@ -52,9 +52,11 @@ it('should migrate simple `@layer utilities` to `@utility`', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
color: red;
}"
}
"
`)
})
@ -69,13 +71,15 @@ it('should split multiple selectors in separate utilities', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
color: red;
}
@utility bar {
color: red;
}"
}
"
`)
})
@ -99,14 +103,16 @@ it('should merge `@utility` with the same name', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
color: red;
font-weight: bold;
}
.bar {
color: blue;
}"
}
"
`)
})
@ -149,7 +155,8 @@ it('should leave non-class utilities alone', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
/* 2. */
/* 2.1. */
color: red;
@ -182,7 +189,58 @@ it('should leave non-class utilities alone', async () => {
font-weight: bold;
}
}
}"
}
"
`)
})
it('should not convert empty classes into `@utility` since that would make them invalid', async () => {
expect(
await migrate(css`
@layer utilities {
.good {
color: red;
}
.bad-empty {
}
/* Comment above */
.bad-with-comment-above {
}
.bad-with-comment-inside {
/* Comment inside */
}
.bad-with-comment-below {
}
/* Comment below */
}
`),
).toMatchInlineSnapshot(`
"
@utility good {
color: red;
}
@layer utilities {
.bad-empty {
}
/* Comment above */
.bad-with-comment-above {
}
.bad-with-comment-inside {
/* Comment inside */
}
.bad-with-comment-below {
}
/* Comment below */
}
"
`)
})
@ -204,7 +262,8 @@ it('should migrate simple `@layer utilities` with nesting to `@utility`', async
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
color: red;
&:hover {
@ -214,7 +273,8 @@ it('should migrate simple `@layer utilities` with nesting to `@utility`', async
&:focus {
color: green;
}
}"
}
"
`)
})
@ -232,13 +292,15 @@ it('should migrate multiple simple `@layer utilities` to `@utility`', async () =
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
color: red;
}
@utility bar {
color: blue;
}"
}
"
`)
})
@ -260,7 +322,8 @@ it('should not migrate Rules inside of Rules to a `@utility`', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
color: red;
}
@ -270,7 +333,8 @@ it('should not migrate Rules inside of Rules to a `@utility`', async () => {
.baz {
color: green;
}
}"
}
"
`)
})
@ -286,11 +350,13 @@ it('should invert at-rules to make them migrate-able', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
@media (min-width: 640px) {
color: red;
}
}"
}
"
`)
})
@ -314,7 +380,8 @@ it('should migrate at-rules with multiple utilities and invert them', async () =
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
@media (min-width: 640px) {
color: red;
}
@ -324,7 +391,8 @@ it('should migrate at-rules with multiple utilities and invert them', async () =
@media (min-width: 640px) {
color: blue;
}
}"
}
"
`)
})
@ -356,7 +424,8 @@ it('should migrate deeply nested at-rules with multiple utilities and invert the
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
@media (min-width: 640px) {
color: red;
}
@ -384,7 +453,8 @@ it('should migrate deeply nested at-rules with multiple utilities and invert the
}
}
}
}"
}
"
`)
})
@ -398,11 +468,13 @@ it('should migrate classes with pseudo elements', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
"
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
}"
}
"
`)
})
@ -416,11 +488,13 @@ it('should migrate classes with attribute selectors', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
"
@utility no-scrollbar {
&[data-checked=''] {
display: none;
}
}"
}
"
`)
})
@ -434,11 +508,13 @@ it('should migrate classes with element selectors', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
"
@utility no-scrollbar {
& main {
display: none;
}
}"
}
"
`)
})
@ -452,11 +528,13 @@ it('should migrate classes attached to an element selector', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
"
@utility no-scrollbar {
&main {
display: none;
}
}"
}
"
`)
})
@ -470,11 +548,13 @@ it('should migrate classes with id selectors', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
"
@utility no-scrollbar {
&#main {
display: none;
}
}"
}
"
`)
})
@ -488,7 +568,8 @@ it('should migrate classes with another attached class', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility no-scrollbar {
"
@utility no-scrollbar {
&.main {
display: none;
}
@ -498,7 +579,8 @@ it('should migrate classes with another attached class', async () => {
&.no-scrollbar {
display: none;
}
}"
}
"
`)
})
@ -512,7 +594,8 @@ it('should migrate a selector with multiple classes to multiple @utility definit
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
& .bar:hover .baz:focus {
display: none;
}
@ -528,7 +611,8 @@ it('should migrate a selector with multiple classes to multiple @utility definit
.foo .bar:hover &:focus {
display: none;
}
}"
}
"
`)
})
@ -548,7 +632,8 @@ it('should merge `@utility` definitions with the same name', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility step {
"
@utility step {
counter-increment: step;
&:before {
@ -556,7 +641,8 @@ it('should merge `@utility` definitions with the same name', async () => {
@apply ml-[-41px];
content: counter(step);
}
}"
}
"
`)
})
@ -570,7 +656,8 @@ it('should not migrate nested classes inside a `:not(…)`', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
& .bar:not(.qux):has(.baz) {
display: none;
}
@ -586,7 +673,8 @@ it('should not migrate nested classes inside a `:not(…)`', async () => {
.foo .bar:not(.qux):has(&) {
display: none;
}
}"
}
"
`)
})
@ -620,7 +708,8 @@ it('should migrate advanced combinations', async () => {
}
`),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
@media (width >= 100px) {
@supports (display: none) {
& .bar:not(.qux):has(.baz) {
@ -659,7 +748,8 @@ it('should migrate advanced combinations', async () => {
}
}
}
}"
}
"
`)
})
@ -677,12 +767,14 @@ describe('comments', () => {
}
`),
).toMatchInlineSnapshot(`
"/* Start of utilities: */
"
/* Start of utilities: */
@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
}"
}
"
`)
})
@ -699,7 +791,8 @@ describe('comments', () => {
}
`),
).toMatchInlineSnapshot(`
"/* Start of utilities: */
"
/* Start of utilities: */
@utility foo {
/* Foo & Bar */
& .bar {
@ -713,7 +806,8 @@ describe('comments', () => {
/* Declarations: */
color: red;
}
}"
}
"
`)
})
@ -733,7 +827,8 @@ describe('comments', () => {
}
`),
).toMatchInlineSnapshot(`
"/* Start of utilities: */
"
/* Start of utilities: */
@utility foo {
/* Mobile only */
@media (width <= 640px) {
@ -741,7 +836,8 @@ describe('comments', () => {
/* Declarations: */
color: red;
}
}"
}
"
`)
})
@ -780,7 +876,8 @@ describe('comments', () => {
/* After */
`),
).toMatchInlineSnapshot(`
"/* Tailwind Utilities: */
"
/* Tailwind Utilities: */
@utility no-scrollbar {
/* Chrome, Safari and Opera */
/* Second comment */
@ -807,7 +904,8 @@ describe('comments', () => {
.after {
/* Inside */
}
/* After */"
/* After */
"
`)
})
})
@ -832,7 +930,8 @@ it('should not lose attribute selectors', async () => {
}
`),
).toMatchInlineSnapshot(`
"@layer components {
"
@layer components {
#TableOfContents {
.toc a {
@apply block max-w-full truncate py-1 pl-2 hover:font-medium hover:no-underline;
@ -845,7 +944,8 @@ it('should not lose attribute selectors', async () => {
}
}
}
}"
}
"
`)
})
@ -863,11 +963,13 @@ describe('layered stylesheets', () => {
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
}"
}
"
`)
})
@ -884,11 +986,13 @@ describe('layered stylesheets', () => {
layers: ['components'],
}),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
}"
}
"
`)
})
@ -905,11 +1009,13 @@ describe('layered stylesheets', () => {
layers: ['foo'],
}),
).toMatchInlineSnapshot(`
"/* Utility #1 */
"
/* Utility #1 */
.foo {
/* Declarations: */
color: red;
}"
}
"
`)
})
@ -929,7 +1035,8 @@ describe('layered stylesheets', () => {
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
/* Utility #1 */
/* Declarations: */
color: red;
@ -937,7 +1044,8 @@ describe('layered stylesheets', () => {
#main {
color: red;
}"
}
"
`)
})
@ -978,7 +1086,8 @@ describe('layered stylesheets', () => {
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@utility foo {
"
@utility foo {
@layer utilities {
@layer utilities {
/* Utility #1 */
@ -1011,7 +1120,8 @@ describe('layered stylesheets', () => {
#secondary {
color: red;
}"
}
"
`)
})
@ -1028,11 +1138,13 @@ describe('layered stylesheets', () => {
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@import 'thing';
"
@import 'thing';
@utility foo {
color: red;
}"
}
"
`)
})
@ -1049,11 +1161,13 @@ describe('layered stylesheets', () => {
layers: ['utilities'],
}),
).toMatchInlineSnapshot(`
"@charset "utf-8";
"
@charset "utf-8";
@utility foo {
color: red;
}"
}
"
`)
})
})

View file

@ -1,4 +1,4 @@
import { type AtRule, type Comment, type Plugin, type Rule } from 'postcss'
import { type AtRule, type Comment, type Container, type Plugin, type Rule } from 'postcss'
import SelectorParser from 'postcss-selector-parser'
import { segment } from '../../../../tailwindcss/src/utils/segment'
import { Stylesheet } from '../../stylesheet'
@ -43,6 +43,7 @@ export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin {
walk(atRule, (node) => {
if (node.type !== 'rule') return
if (isEmpty(node)) return
// Find all the classes in the selector
SelectorParser((selectors) => {
@ -184,6 +185,18 @@ export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin {
toRemove.push(node.parent?.nodes.at(i) as Comment)
}
}
let commentsAfter: Comment[] = []
for (let i = idx + 1; i < (node.parent?.nodes.length ?? 0); i++) {
if (node.parent?.nodes.at(i)?.type === 'rule') {
commentsAfter = []
break
}
if (node.parent?.nodes.at(i)?.type === 'comment') {
commentsAfter.push(node.parent?.nodes.at(i) as Comment)
}
}
toRemove.push(...commentsAfter)
}
for (let node of toRemove) {
node.remove()
@ -231,8 +244,12 @@ export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin {
}
}
// Remove empty rules
if ((node.type === 'rule' || node.type === 'atrule') && node.nodes?.length === 0) {
// Remove empty rules from `@utility` clones and empty wrapper at-rules
// from the default `@layer` clone.
if (
(node.type === 'atrule' && node.nodes?.length === 0) ||
(clone !== defaultsAtRule && node.type === 'rule' && node.nodes?.length === 0)
) {
node.remove()
}
@ -261,6 +278,11 @@ export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin {
// Remove empty clones entirely
if (clone.nodes?.length === 0) {
clones.splice(idx, 1)
} else if (clone === defaultsAtRule) {
let first = clone.nodes?.[0]
if (first) {
first.raws.before = first.raws.before?.replace(/\n\s*\n/g, '\n')
}
}
}
@ -316,3 +338,17 @@ export function migrateAtLayerUtilities(stylesheet: Stylesheet): Plugin {
},
}
}
function isEmpty(node: Container): boolean {
// We have no body
if (node.nodes === undefined) return false
// We have a body, but no nodes
if (node.nodes.length === 0) return true
// We have nodes, but they are only comments which we consider empty
if (node.nodes.every((child) => child.type === 'comment')) return true
// Not empty
return false
}

View file

@ -27850,7 +27850,7 @@ test('inset-shadow', async () => {
}
.inset-shadow-none {
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow: inset 0 0 #0000;
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}

View file

@ -5734,7 +5734,7 @@ export function createUtilities(theme: Theme) {
if (candidate.modifier) return
return [
boxShadowProperties(),
decl('--tw-inset-shadow', nullShadow),
decl('--tw-inset-shadow', `inset ${nullShadow}`),
decl('box-shadow', cssBoxShadowValue),
]

View file

@ -10,9 +10,9 @@
"dependencies": {
"@tailwindcss/postcss": "workspace:^",
"fast-glob": "^3.3.3",
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6",
"next": "16.2.7",
"react": "19.2.7",
"react-dom": "19.2.7",
"tailwindcss": "workspace:^"
},
"devDependencies": {

View file

@ -9,9 +9,9 @@
"upgrade": "node scripts/upgrade.mjs"
},
"dependencies": {
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6",
"next": "16.2.7",
"react": "19.2.7",
"react-dom": "19.2.7",
"tailwindcss": "^3"
},
"devDependencies": {

128
pnpm-lock.yaml generated
View file

@ -516,14 +516,14 @@ importers:
specifier: ^3.3.3
version: 3.3.3
next:
specifier: 16.2.6
version: 16.2.6(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
specifier: 16.2.7
version: 16.2.7(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react:
specifier: 19.2.6
version: 19.2.6
specifier: 19.2.7
version: 19.2.7
react-dom:
specifier: 19.2.6
version: 19.2.6(react@19.2.6)
specifier: 19.2.7
version: 19.2.7(react@19.2.7)
tailwindcss:
specifier: workspace:^
version: link:../../packages/tailwindcss
@ -544,14 +544,14 @@ importers:
playgrounds/v3:
dependencies:
next:
specifier: 16.2.6
version: 16.2.6(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
specifier: 16.2.7
version: 16.2.7(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react:
specifier: 19.2.6
version: 19.2.6
specifier: 19.2.7
version: 19.2.7
react-dom:
specifier: 19.2.6
version: 19.2.6(react@19.2.6)
specifier: 19.2.7
version: 19.2.7(react@19.2.7)
tailwindcss:
specifier: ^3
version: 3.4.19
@ -1400,53 +1400,53 @@ packages:
resolution: {integrity: sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ==}
engines: {node: '>= 10'}
'@next/env@16.2.6':
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
'@next/env@16.2.7':
resolution: {integrity: sha512-tMJizPlj6ZYpBMMdK8S0LJufrP4QTdR6pcv9KQ/bVETPAmg0j1mlHE9G2c38UyGHxoBapgwuj7XjbGJ2RcDFOg==}
'@next/swc-darwin-arm64@16.2.6':
resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==}
'@next/swc-darwin-arm64@16.2.7':
resolution: {integrity: sha512-vm1EDI/pVaBNNiychmxk3fft+OhQPVD9cIM/tReLZIQ3TfQ4kqI9DwKk00dzuS1ulC7icbrzCFrmRRlk9PfNdw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.2.6':
resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==}
'@next/swc-darwin-x64@16.2.7':
resolution: {integrity: sha512-O3IRSv1ZBL1zs0WrIgefTEcTKFVn+ryxBNe54erJ6KsD+2f/Mmt7g2jOYh8PSBdUwPtKQJuCsTMlZ7tIu2AcsQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.2.6':
resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==}
'@next/swc-linux-arm64-gnu@16.2.7':
resolution: {integrity: sha512-Re6PZtjBDd0aMU+VcZcC/PrIvj4WhrjDYtMhhCVQamWN4L90EVP0pcEOBQD25prSlw7OzNw5QpHLWMilRLsRNw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@16.2.6':
resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==}
'@next/swc-linux-arm64-musl@16.2.7':
resolution: {integrity: sha512-qyogG9QtBzWxgJfeGBvOEHI3851gTfCF3wLZ5RDLTBJGAmE9p1qDwKCOdrBrvBzRvYDT+gUDp72pzlSEfAXgNA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@16.2.6':
resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==}
'@next/swc-linux-x64-gnu@16.2.7':
resolution: {integrity: sha512-Vhe4ZDuBpmMogrGi5D4R2Kq4JAQlj6+wvgaFYy31zfES0zPmt6TLA+cuYpM/OLrPZjo2MYQTHVqNUSCR6+fDZQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@16.2.6':
resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==}
'@next/swc-linux-x64-musl@16.2.7':
resolution: {integrity: sha512-srvian89JahFLw1YLBEuhvPJ0DO5lpUeJQMXy4xYo7g628ZlNgXdNkqoxSAv9OYrBfByh6vxISMwW/mRbzCY+g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@16.2.6':
resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==}
'@next/swc-win32-arm64-msvc@16.2.7':
resolution: {integrity: sha512-GX3wvLpULFuRFJzwHaKfm7QZJ18F4ZSuxlPJ96BoBglCzBmdSjyeBKF+ZhWhvL/ckxNfLnNa7bsObO2ipYpszw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.2.6':
resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==}
'@next/swc-win32-x64-msvc@16.2.7':
resolution: {integrity: sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -2834,8 +2834,8 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
next@16.2.6:
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
next@16.2.7:
resolution: {integrity: sha512-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@ -3031,10 +3031,19 @@ packages:
peerDependencies:
react: ^19.2.6
react-dom@19.2.7:
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
peerDependencies:
react: ^19.2.7
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@ -4061,30 +4070,30 @@ snapshots:
- '@emnapi/core'
- '@emnapi/runtime'
'@next/env@16.2.6': {}
'@next/env@16.2.7': {}
'@next/swc-darwin-arm64@16.2.6':
'@next/swc-darwin-arm64@16.2.7':
optional: true
'@next/swc-darwin-x64@16.2.6':
'@next/swc-darwin-x64@16.2.7':
optional: true
'@next/swc-linux-arm64-gnu@16.2.6':
'@next/swc-linux-arm64-gnu@16.2.7':
optional: true
'@next/swc-linux-arm64-musl@16.2.6':
'@next/swc-linux-arm64-musl@16.2.7':
optional: true
'@next/swc-linux-x64-gnu@16.2.6':
'@next/swc-linux-x64-gnu@16.2.7':
optional: true
'@next/swc-linux-x64-musl@16.2.6':
'@next/swc-linux-x64-musl@16.2.7':
optional: true
'@next/swc-win32-arm64-msvc@16.2.6':
'@next/swc-win32-arm64-msvc@16.2.7':
optional: true
'@next/swc-win32-x64-msvc@16.2.6':
'@next/swc-win32-x64-msvc@16.2.7':
optional: true
'@nodelib/fs.scandir@2.1.5':
@ -5240,25 +5249,25 @@ snapshots:
neo-async@2.6.2: {}
next@16.2.6(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
next@16.2.7(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@next/env': 16.2.6
'@next/env': 16.2.7
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.31
caniuse-lite: 1.0.30001793
postcss: 8.4.31
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-jsx: 5.1.6(react@19.2.6)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
styled-jsx: 5.1.6(react@19.2.7)
optionalDependencies:
'@next/swc-darwin-arm64': 16.2.6
'@next/swc-darwin-x64': 16.2.6
'@next/swc-linux-arm64-gnu': 16.2.6
'@next/swc-linux-arm64-musl': 16.2.6
'@next/swc-linux-x64-gnu': 16.2.6
'@next/swc-linux-x64-musl': 16.2.6
'@next/swc-win32-arm64-msvc': 16.2.6
'@next/swc-win32-x64-msvc': 16.2.6
'@next/swc-darwin-arm64': 16.2.7
'@next/swc-darwin-x64': 16.2.7
'@next/swc-linux-arm64-gnu': 16.2.7
'@next/swc-linux-arm64-musl': 16.2.7
'@next/swc-linux-x64-gnu': 16.2.7
'@next/swc-linux-x64-musl': 16.2.7
'@next/swc-win32-arm64-msvc': 16.2.7
'@next/swc-win32-x64-msvc': 16.2.7
'@playwright/test': 1.60.0
sharp: 0.34.5
transitivePeerDependencies:
@ -5413,8 +5422,15 @@ snapshots:
react: 19.2.6
scheduler: 0.27.0
react-dom@19.2.7(react@19.2.7):
dependencies:
react: 19.2.7
scheduler: 0.27.0
react@19.2.6: {}
react@19.2.7: {}
read-cache@1.0.0:
dependencies:
pify: 2.3.0
@ -5560,10 +5576,10 @@ snapshots:
std-env@4.1.0: {}
styled-jsx@5.1.6(react@19.2.6):
styled-jsx@5.1.6(react@19.2.7):
dependencies:
client-only: 0.0.1
react: 19.2.6
react: 19.2.7
sucrase@3.35.1:
dependencies: