diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f4a575b5..4a2aecbc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure opacity modifier with variables work with `color-mix()` ([#13972](https://github.com/tailwindlabs/tailwindcss/pull/13972)) - Discard invalid `variants` and `utilities` with modifiers ([#13977](https://github.com/tailwindlabs/tailwindcss/pull/13977)) - Add missing utilities that exist in v3, such as `resize`, `fill-none`, `accent-none`, `drop-shadow-none`, and negative `hue-rotate` and `backdrop-hue-rotate` utilities ([#13971](https://github.com/tailwindlabs/tailwindcss/pull/13971)) +- Don’t allow at-rule-only variants to be compounded ([#14015](https://github.com/tailwindlabs/tailwindcss/pull/14015)) ### Added diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 9c1021fb4..e22b0a953 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -725,6 +725,15 @@ test('group-[...]', () => { display: flex; }" `) + + expect( + compileCss( + css` + @tailwind utilities; + `, + ['group-[@media_foo]:flex'], + ), + ).toEqual('') }) test('group-*', () => { @@ -752,6 +761,16 @@ test('group-*', () => { display: flex; }" `) + + expect( + compileCss( + css` + @variant custom-at-rule (@media foo); + @tailwind utilities; + `, + ['group-custom-at-rule:flex'], + ), + ).toEqual('') }) test('peer-[...]', () => { @@ -784,6 +803,15 @@ test('peer-[...]', () => { display: flex; }" `) + + expect( + compileCss( + css` + @tailwind utilities; + `, + ['peer-[@media_foo]:flex'], + ), + ).toEqual('') }) test('peer-*', () => { @@ -811,6 +839,16 @@ test('peer-*', () => { display: flex; }" `) + + expect( + compileCss( + css` + @variant custom-at-rule (@media foo); + @tailwind utilities; + `, + ['peer-custom-at-rule:flex'], + ), + ).toEqual('') }) test('ltr', () => { @@ -1505,7 +1543,15 @@ test('not', () => { }" `) - expect(run(['not-[:checked]/foo:flex'])).toEqual('') + expect( + compileCss( + css` + @variant custom-at-rule (@media foo); + @tailwind utilities; + `, + ['not-[:checked]/foo:flex', 'not-[@media_print]:flex', 'not-custom-at-rule:flex'], + ), + ).toEqual('') }) test('has', () => { @@ -1518,7 +1564,7 @@ test('has', () => { 'group-has-checked:flex', 'peer-has-[:checked]:flex', - 'peer-has-[:checked]/parent-name:flex', + 'peer-has-[:checked]/sibling-name:flex', 'peer-has-checked:flex', ]), ).toMatchInlineSnapshot(` @@ -1542,7 +1588,7 @@ test('has', () => { display: flex; } - .peer-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):has(:checked) ~ *) { + .peer-has-\\[\\:checked\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(:checked) ~ *) { display: flex; } @@ -1550,7 +1596,16 @@ test('has', () => { display: flex; }" `) - expect(run(['has-[:checked]/foo:flex'])).toEqual('') + + expect( + compileCss( + css` + @variant custom-at-rule (@media foo); + @tailwind utilities; + `, + ['has-[:checked]/foo:flex', 'has-[@media_print]:flex', 'has-custom-at-rule:flex'], + ), + ).toEqual('') }) test('aria', () => { diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 5be68c489..c3c50bc63 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -205,7 +205,28 @@ export function createVariants(theme: Theme): Variants { variants.compound('not', (ruleNode, variant) => { if (variant.modifier) return null - ruleNode.selector = `&:not(${ruleNode.selector.replace('&', '*')})` + + let didApply = false + + walk([ruleNode], (node) => { + if (node.kind !== 'rule') return WalkAction.Continue + + // Skip past at-rules, and continue traversing the children of the at-rule + if (node.selector[0] === '@') return WalkAction.Continue + + // Replace `&` in target variant with `*`, so variants like `&:hover` + // become `&:not(*:hover)`. The `*` will often be optimized away. + node.selector = `&:not(${node.selector.replace('&', '*')})` + + // Track that the variant was actually applied + didApply = true + }) + + // If the node wasn't modified, this variant is not compatible with + // `not-*` so discard the candidate. + if (!didApply) { + return null + } }) variants.compound('group', (ruleNode, variant) => { @@ -215,6 +236,8 @@ export function createVariants(theme: Theme): Variants { ? `:where(.group\\/${variant.modifier.value})` : ':where(.group)' + let didApply = false + walk([ruleNode], (node) => { if (node.kind !== 'rule') return WalkAction.Continue @@ -234,10 +257,17 @@ export function createVariants(theme: Theme): Variants { node.selector = `:is(${node.selector})` } - // Use `:where` to make sure the specificity of group variants isn't higher - // than the specificity of other variants. node.selector = `&:is(${node.selector} *)` + + // Track that the variant was actually applied + didApply = true }) + + // If the node wasn't modified, this variant is not compatible with + // `group-*` so discard the candidate. + if (!didApply) { + return null + } }) variants.suggest('group', () => { @@ -253,6 +283,8 @@ export function createVariants(theme: Theme): Variants { ? `:where(.peer\\/${variant.modifier.value})` : ':where(.peer)' + let didApply = false + walk([ruleNode], (node) => { if (node.kind !== 'rule') return WalkAction.Continue @@ -272,10 +304,17 @@ export function createVariants(theme: Theme): Variants { node.selector = `:is(${node.selector})` } - // Use `:where` to make sure the specificity of group variants isn't higher - // than the specificity of other variants. node.selector = `&:is(${node.selector} ~ *)` + + // Track that the variant was actually applied + didApply = true }) + + // If the node wasn't modified, this variant is not compatible with + // `peer-*` so discard the candidate. + if (!didApply) { + return null + } }) variants.suggest('peer', () => { @@ -392,7 +431,28 @@ export function createVariants(theme: Theme): Variants { variants.compound('has', (ruleNode, variant) => { if (variant.modifier) return null - ruleNode.selector = `&:has(${ruleNode.selector.replace('&', '*')})` + + let didApply = false + + walk([ruleNode], (node) => { + if (node.kind !== 'rule') return WalkAction.Continue + + // Skip past at-rules, and continue traversing the children of the at-rule + if (node.selector[0] === '@') return WalkAction.Continue + + // Replace `&` in target variant with `*`, so variants like `&:hover` + // become `&:has(*:hover)`. The `*` will often be optimized away. + node.selector = `&:has(${node.selector.replace('&', '*')})` + + // Track that the variant was actually applied + didApply = true + }) + + // If the node wasn't modified, this variant is not compatible with + // `has-*` so discard the candidate. + if (!didApply) { + return null + } }) variants.suggest('has', () => {