Reject candidates with multiple modifiers (#20466)

<!--

👋 Hey, thanks for your interest in contributing to Tailwind!

**Please ask first before starting work on any significant new
features.**

It's never a fun experience to have your pull request declined after
investing a lot of time and effort into a new feature. To avoid this
from happening, we request that contributors create a discussion to
first discuss any significant new features.

For more info, check out the contributing guide:


https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md

-->

## Summary

<!--

Provide a summary of the issue and the changes you're making. How does
your change solve the problem?

-->

`segment()` preserves empty top-level segments, but the candidate and
variant parsers currently use the truthiness of the third segment to
detect additional modifiers.

As a result, inputs such as `bg-red-500/50/`, `bg-red-500/50//foo`,
`group-hover/foo/:flex`, and `group-hover/foo//bar:flex` can be parsed
as valid candidates even though they contain multiple slash modifier
segments.

This change checks the number of segments instead of the value of the
third segment. Single modifiers such as `bg-red-500/50` and
`group-hover/foo:flex` continue to parse normally, while all additional
top-level `/` segments are rejected.


## Test plan

<!--

Explain how you tested your changes. Include the exact commands that you
used to verify the change works and include screenshots/screen
recordings of the update behavior in the browser if applicable.

-->

- `pnpm exec vitest run packages/tailwindcss/src/candidate.test.ts
--hideSkippedTests`
- `pnpm exec vitest run packages/tailwindcss/src --hideSkippedTests`
- `pnpm exec prettier --check packages/tailwindcss/src/candidate.ts
packages/tailwindcss/src/candidate.test.ts`

`pnpm --filter=tailwindcss lint` was also attempted, but currently fails
on existing cross-package dependency and Bun type errors unrelated to
this change.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
cui fliter 2026-09-08 21:46:59 +08:00 • committed by GitHub
parent f723e834ad
commit 41d9cae8e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 32 additions and 14 deletions

View file

@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Don't generate utilities when a modifier is used that would otherwise be silently ignored (e.g. `rounded-sm/[5]`, `shadow-sm/foo`, `stroke-2/50`) ([#20419](https://github.com/tailwindlabs/tailwindcss/pull/20419))
- Only normalize top-level `and`, `or`, and `not` keywords in `supports-[…]` variants (e.g. `selector(a: not (.foo))` → `selector(a:not(.foo))`) ([#20420](https://github.com/tailwindlabs/tailwindcss/pull/20420))
- Don't warn about Angular's `::ng-deep` and `:host-context()` when optimizing CSS ([#20434](https://github.com/tailwindlabs/tailwindcss/pull/20434))
- Don't generate CSS for candidates containing an empty additional modifier (e.g. `bg-red-500/50/` and `group-hover/foo//bar:flex`) ([#20466](https://github.com/tailwindlabs/tailwindcss/pull/20466))
## [4.3.3] - 2026-07-16

View file

@ -516,6 +516,8 @@ it('should not parse functional utilities with multiple modifiers', () => {
utilities.functional('bg', () => [])
expect(run('bg-red-1/2/3', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-red-500/50/', { utilities })).toMatchInlineSnapshot(`[]`)
expect(run('bg-red-500/50//foo', { utilities })).toMatchInlineSnapshot(`[]`)
})
it('should parse a utility with an arbitrary value', () => {
@ -1416,6 +1418,19 @@ it('should parse a functional variant with a modifier', () => {
`)
})
it('should not parse variants with multiple modifiers', () => {
let utilities = new Utilities()
utilities.static('flex', () => [])
let variants = new Variants()
variants.static('hover', () => {})
variants.compound('group', Compounds.StyleRules, () => {})
expect(run('group-hover/foo:flex', { utilities, variants })).toHaveLength(1)
expect(run('group-hover/foo/:flex', { utilities, variants })).toEqual([])
expect(run('group-hover/foo//bar:flex', { utilities, variants })).toEqual([])
})
it('should parse a functional variant starting with @', () => {
let utilities = new Utilities()
utilities.static('flex', () => [])

View file

@ -372,6 +372,14 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
}
}
// If there's more than one modifier, the utility is invalid.
//
// E.g.:
//
// - `bg-red-500/50/50`
let parts = segment(base, '/')
if (parts.length > 2) return
// Figure out the new base and the modifier segment if present.
//
// E.g.:
@ -381,14 +389,7 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter
// ^^^^^^^^^^ -> Base without modifier
// ^^ -> Modifier segment
// ```
let [baseWithoutModifier, modifierSegment = null, additionalModifier] = segment(base, '/')
// If there's more than one modifier, the utility is invalid.
//
// E.g.:
//
// - `bg-red-500/50/50`
if (additionalModifier) return
let [baseWithoutModifier, modifierSegment = null] = parts
let parsedModifier = modifierSegment === null ? null : parseModifier(modifierSegment)
@ -707,17 +708,18 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
// Static, functional and compound variants
{
// group-hover/group-name
// ^^^^^^^^^^^ -> Variant without modifier
// ^^^^^^^^^^ -> Modifier
let [variantWithoutModifier, modifier = null, additionalModifier] = segment(variant, '/')
// If there's more than one modifier, the variant is invalid.
//
// E.g.:
//
// - `group-hover/foo/bar`
if (additionalModifier) return null
let parts = segment(variant, '/')
if (parts.length > 2) return null
// group-hover/group-name
// ^^^^^^^^^^^ -> Variant without modifier
// ^^^^^^^^^^ -> Modifier
let [variantWithoutModifier, modifier = null] = parts
let roots = findRoots(variantWithoutModifier, (root) => {
return designSystem.variants.has(root)