Ignore custom variants with :merge(…) selectors (#18020)

Closes #15617

## Summary

This PR ignores `addVariant(…)` legacy JS plugin calls for variants that
are using the [`:merge(…)`
selector](https://v3.tailwindcss.com/docs/plugins#parent-and-sibling-states)
for parent and sibling states. We can ignore these now because in v4,
`group-*` and `peer-*` variants _compound automatically_ and you don't
have to define them anymore.

## Test plan

Added a unit test to ensure that the `optional` variant example from the
v3 docs work as expected.
This commit is contained in:
Philipp Spiess 2025-05-15 13:00:00 +02:00 committed by GitHub
parent e57a2f5a3a
commit f3157cd9a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 104 additions and 0 deletions

View File

@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure that running the Standalone build does not leave temporary files behind ([#17981](https://github.com/tailwindlabs/tailwindcss/pull/17981))
- Fix `-rotate-*` utilities with arbitrary values ([#18014](https://github.com/tailwindlabs/tailwindcss/pull/18014))
- Upgrade: Change casing of utilities with named values to kebab-case to match updated theme variables ([#18017](https://github.com/tailwindlabs/tailwindcss/pull/18017))
- Ignore custom variants using `:merge(…)` selectors in legacy JS plugins ([#18020](https://github.com/tailwindlabs/tailwindcss/pull/18020))
### Added

View File

@ -1830,6 +1830,44 @@ describe('addVariant', () => {
}"
`)
})
test('ignores variants that use :merge(…) and ensures `peer-*` and `group-*` rules work out of the box', async () => {
let { build } = await compile(
css`
@plugin "my-plugin";
@layer utilities {
@tailwind utilities;
}
`,
{
loadModule: async (id, base) => {
return {
path: '',
base,
module: ({ addVariant }: PluginAPI) => {
addVariant('optional', '&:optional')
addVariant('group-optional', { ':merge(.group):optional &': '@slot' })
addVariant('peer-optional', { '&': { ':merge(.peer):optional ~ &': '@slot' } })
},
}
},
},
)
let compiled = build([
'optional:flex',
'group-optional:flex',
'peer-optional:flex',
'group-optional/foo:flex',
])
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.group-optional\\:flex:is(:where(.group):optional *), .group-optional\\/foo\\:flex:is(:where(.group\\/foo):optional *), .peer-optional\\:flex:is(:where(.peer):optional ~ *), .optional\\:flex:optional {
display: flex;
}
}"
`)
})
})
describe('matchVariant', () => {
@ -2702,6 +2740,44 @@ describe('matchVariant', () => {
}"
`)
})
test('ignores variants that use :merge(…)', async () => {
let { build } = await compile(
css`
@plugin "my-plugin";
@layer utilities {
@tailwind utilities;
}
`,
{
loadModule: async (id, base) => {
return {
path: '',
base,
module: ({ matchVariant }: PluginAPI) => {
matchVariant('optional', (flavor) => `&:optional:has(${flavor}) &`)
matchVariant('group-optional', (flavor) => `:merge(.group):optional:has(${flavor}) &`)
matchVariant('peer-optional', (flavor) => `:merge(.peer):optional:has(${flavor}) ~ &`)
},
}
},
},
)
let compiled = build([
'optional-[test]:flex',
'group-optional-[test]:flex',
'peer-optional-[test]:flex',
'group-optional-[test]/foo:flex',
])
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.group-optional-\\[test\\]\\:flex:is(:where(.group):optional:has(test) :where(.group) *), .group-optional-\\[test\\]\\/foo\\:flex:is(:where(.group\\/foo):optional:has(test) :where(.group\\/foo) *), .peer-optional-\\[test\\]\\:flex:is(:where(.peer):optional:has(test) :where(.peer) ~ *), .optional-\\[test\\]\\:flex:optional:has(test) .optional-\\[test\\]\\:flex {
display: flex;
}
}"
`)
})
})
describe('addUtilities()', () => {

View File

@ -115,6 +115,22 @@ export function buildPluginApi({
)
}
// Ignore variants emitting v3 `:merge(…)` rules. In v4, the `group-*` and `peer-*` variants
// compound automatically.
if (typeof variant === 'string') {
if (variant.includes(':merge(')) return
} else if (Array.isArray(variant)) {
if (variant.some((v) => v.includes(':merge('))) return
} else if (typeof variant === 'object') {
function keyIncludes(object: Record<string, any>, search: string): boolean {
return Object.entries(object).some(
([key, value]) =>
key.includes(search) || (typeof value === 'object' && keyIncludes(value, search)),
)
}
if (keyIncludes(variant, ':merge(')) return
}
// Single selector or multiple parallel selectors
if (typeof variant === 'string' || Array.isArray(variant)) {
designSystem.variants.static(
@ -143,6 +159,17 @@ export function buildPluginApi({
return parseVariantValue(resolved, nodes)
}
try {
// Sample variant value and ignore variants emitting v3 `:merge` rules. In
// v4, the `group-*` and `peer-*` variants compound automatically.
let sample = fn('a', { modifier: null })
if (typeof sample === 'string' && sample.includes(':merge(')) {
return
} else if (Array.isArray(sample) && sample.some((r) => r.includes(':merge('))) {
return
}
} catch {}
let defaultOptionKeys = Object.keys(options?.values ?? {})
designSystem.variants.group(
() => {