Don't emit utilities that resolve a theme value when an unsupported modifier is used (#20419)

## Summary

Modifiers on functional utilities that resolve a named theme value are
silently
dropped instead of invalidating the candidate. For example, with the
default theme:

- `rounded-sm/[5]` emits the same CSS as `rounded-sm` (the `[5]` is
ignored)
- `shadow-sm/foo`, `inset-shadow-sm/foo`, `text-shadow/foo`, and
`drop-shadow/foo`
  emit the full shadow CSS with the invalid `foo` modifier ignored

This is inconsistent with how every sibling code path behaves:
`rounded/foo`,
`rounded-[4px]/foo`, `rounded-sm/5`, and `drop-shadow-xl/foo` all
correctly
produce no output, because those paths check `candidate.modifier`.

This PR adds the missing guards:

- In the generic `functionalUtility` handler, a candidate whose named
value
resolves from the theme now rejects modifiers (except fractions like
`w-1/2`,
  where the modifier is part of the resolved value).
- The `shadow`, `inset-shadow`, and `text-shadow` utilities now apply
the same
`if (candidate.modifier && !alpha) return` guard in their default-value,
arbitrary-value, and named-size branches that `drop-shadow` already
applies,
  and `drop-shadow` gets it in its default-value branch too.

Existing tests asserting candidates like `drop-shadow/foo` produce no
output were
passing for the wrong reason: they run without a theme, so the theme
lookup fails
before the modifier is ever considered. The new tests provide a theme so
the
invalid modifier is what invalidates the candidate.

## Test plan

- Added assertions to the `rounded`, `filter`, `shadow`, `inset-shadow`,
and
`text-shadow` tests that compile candidates with invalid modifiers
against a
theme that defines the relevant values, and expect no output. All of
them fail
  without the fix.
- `pnpm vitest run packages/tailwindcss/src/utilities.test.ts` — 398
passed.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Srinu desetti 2026-08-14 22:17:34 +05:30 • committed by GitHub
parent 021b7fe6f2
commit 7a7f386648
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 230 additions and 143 deletions

View file

@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Oxide: drop invalid UTF-8 candidates ([#20389](https://github.com/tailwindlabs/tailwindcss/pull/20389))
- `@tailwindcss/vite` no longer forces a full page reload for external files (e.g.: `.php` files) ([#20414](https://github.com/tailwindlabs/tailwindcss/issues/20414))
- Canonicalization: don't merge utilities that reference different theme variables set to CSS-wide keywords like `unset` ([#20417](https://github.com/tailwindlabs/tailwindcss/pull/20417))
- 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))
## [4.3.3] - 2026-07-16

View file

@ -12154,18 +12154,28 @@ test('rounded', async () => {
"
`)
expect(
await run([
'-rounded',
'-rounded-full',
'-rounded-none',
'-rounded-sm',
'-rounded-[4px]',
'rounded/foo',
'rounded-full/foo',
'rounded-none/foo',
'rounded-sm/foo',
'rounded-[4px]/foo',
]),
await run(
[
'-rounded',
'-rounded-full',
'-rounded-none',
'-rounded-sm',
'-rounded-[4px]',
'rounded/foo',
'rounded-full/foo',
'rounded-none/foo',
'rounded-sm/foo',
'rounded-[4px]/foo',
'rounded-sm/[5]',
'rounded-sm/(--foo)',
],
css`
@theme {
--radius-sm: 0.125rem;
}
@tailwind utilities;
`,
),
).toEqual('')
})
@ -22373,6 +22383,11 @@ test('stroke', async () => {
// Width
'-stroke-0',
'stroke--1',
'stroke-0/foo',
'stroke-1/50',
'stroke-2/[0.5]',
'stroke-2/(--my-opacity)',
'stroke-[12px]/foo',
]),
).toEqual('')
})
@ -24398,82 +24413,90 @@ test('filter', async () => {
"
`)
expect(
await run([
'-filter',
'-filter-none',
'-filter-[var(--value)]',
'-blur-xl',
'-blur-[4px]',
'brightness--50',
'-brightness-50',
'-brightness-[1.23]',
'brightness-unknown',
'contrast--50',
'-contrast-50',
'-contrast-[1.23]',
'contrast-unknown',
'-grayscale',
'-grayscale-0',
'grayscale--1',
'-grayscale-[var(--value)]',
'grayscale-unknown',
'hue-rotate--5',
'hue-rotate-unknown',
'-invert',
'invert--5',
'-invert-0',
'-invert-[var(--value)]',
'invert-unknown',
'-drop-shadow-xl',
'-drop-shadow-[0_0_red]',
await run(
[
'-filter',
'-filter-none',
'-filter-[var(--value)]',
'-blur-xl',
'-blur-[4px]',
'brightness--50',
'-brightness-50',
'-brightness-[1.23]',
'brightness-unknown',
'contrast--50',
'-contrast-50',
'-contrast-[1.23]',
'contrast-unknown',
'-grayscale',
'-grayscale-0',
'grayscale--1',
'-grayscale-[var(--value)]',
'grayscale-unknown',
'hue-rotate--5',
'hue-rotate-unknown',
'-invert',
'invert--5',
'-invert-0',
'-invert-[var(--value)]',
'invert-unknown',
'-drop-shadow-xl',
'-drop-shadow-[0_0_red]',
'drop-shadow/foo',
'-drop-shadow/foo',
'-drop-shadow/25',
'-drop-shadow-red-500',
'drop-shadow-red-500/foo',
'-drop-shadow-red-500/foo',
'-drop-shadow-red-500/50',
'drop-shadow/foo',
'-drop-shadow/foo',
'-drop-shadow/25',
'-drop-shadow-red-500',
'drop-shadow-red-500/foo',
'-drop-shadow-red-500/foo',
'-drop-shadow-red-500/50',
'-saturate-0',
'saturate--5',
'-saturate-[1.75]',
'-saturate-[var(--value)]',
'saturate-saturate',
'-sepia',
'sepia--50',
'-sepia-0',
'-sepia-[50%]',
'-sepia-[var(--value)]',
'sepia-unknown',
'filter/foo',
'filter-none/foo',
'filter-[var(--value)]/foo',
'blur-xl/foo',
'blur-none/foo',
'blur-[4px]/foo',
'brightness-50/foo',
'brightness-[1.23]/foo',
'contrast-50/foo',
'contrast-[1.23]/foo',
'grayscale/foo',
'grayscale-0/foo',
'grayscale-[var(--value)]/foo',
'hue-rotate-15/foo',
'hue-rotate-[45deg]/foo',
'invert/foo',
'invert-0/foo',
'invert-[var(--value)]/foo',
'drop-shadow-xl/foo',
'drop-shadow-[0_0_red]/foo',
'saturate-0/foo',
'saturate-[1.75]/foo',
'saturate-[var(--value)]/foo',
'sepia/foo',
'sepia-0/foo',
'sepia-[50%]/foo',
'sepia-[var(--value)]/foo',
]),
'-saturate-0',
'saturate--5',
'-saturate-[1.75]',
'-saturate-[var(--value)]',
'saturate-saturate',
'-sepia',
'sepia--50',
'-sepia-0',
'-sepia-[50%]',
'-sepia-[var(--value)]',
'sepia-unknown',
'filter/foo',
'filter-none/foo',
'filter-[var(--value)]/foo',
'blur-xl/foo',
'blur-none/foo',
'blur-[4px]/foo',
'brightness-50/foo',
'brightness-[1.23]/foo',
'contrast-50/foo',
'contrast-[1.23]/foo',
'grayscale/foo',
'grayscale-0/foo',
'grayscale-[var(--value)]/foo',
'hue-rotate-15/foo',
'hue-rotate-[45deg]/foo',
'invert/foo',
'invert-0/foo',
'invert-[var(--value)]/foo',
'drop-shadow-xl/foo',
'drop-shadow-[0_0_red]/foo',
'saturate-0/foo',
'saturate-[1.75]/foo',
'saturate-[var(--value)]/foo',
'sepia/foo',
'sepia-0/foo',
'sepia-[50%]/foo',
'sepia-[var(--value)]/foo',
],
css`
@theme {
--drop-shadow: 0 1px 1px rgb(0 0 0 / 0.05);
}
@tailwind utilities;
`,
),
).toEqual('')
expect(
@ -27243,25 +27266,36 @@ test('text-shadow', async () => {
"
`)
expect(
await run([
'-shadow-xl',
'-shadow-none',
'-shadow-red-500',
'-shadow-red-500/50',
'-shadow-red-500/[0.5]',
'-shadow-red-500/[50%]',
'-shadow-current',
'-shadow-current/50',
'-shadow-current/[0.5]',
'-shadow-current/[50%]',
'-shadow-inherit',
'-shadow-transparent',
'-shadow-[#0088cc]',
'-shadow-[#0088cc]/50',
'-shadow-[#0088cc]/[0.5]',
'-shadow-[#0088cc]/[50%]',
'-shadow-[var(--value)]',
]),
await run(
[
'-text-shadow-sm',
'-text-shadow-none',
'-text-shadow-red-500',
'-text-shadow-red-500/50',
'-text-shadow-red-500/[0.5]',
'-text-shadow-red-500/[50%]',
'-text-shadow-current',
'-text-shadow-current/50',
'-text-shadow-current/[0.5]',
'-text-shadow-current/[50%]',
'-text-shadow-inherit',
'-text-shadow-transparent',
'-text-shadow-[#0088cc]',
'-text-shadow-[#0088cc]/50',
'-text-shadow-[#0088cc]/[0.5]',
'-text-shadow-[#0088cc]/[50%]',
'-text-shadow-[var(--value)]',
'text-shadow/foo',
'text-shadow-sm/foo',
],
css`
@theme {
--text-shadow: 0 1px 1px rgb(0 0 0 / 0.05);
--text-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.06);
}
@tailwind utilities;
`,
),
).toEqual('')
})
@ -27725,25 +27759,38 @@ test('shadow', async () => {
"
`)
expect(
await run([
'-shadow-xl',
'-shadow-none',
'-shadow-red-500',
'-shadow-red-500/50',
'-shadow-red-500/[0.5]',
'-shadow-red-500/[50%]',
'-shadow-current',
'-shadow-current/50',
'-shadow-current/[0.5]',
'-shadow-current/[50%]',
'-shadow-inherit',
'-shadow-transparent',
'-shadow-[#0088cc]',
'-shadow-[#0088cc]/50',
'-shadow-[#0088cc]/[0.5]',
'-shadow-[#0088cc]/[50%]',
'-shadow-[var(--value)]',
]),
await run(
[
'-shadow-sm',
'-shadow-none',
'-shadow-red-500',
'-shadow-red-500/50',
'-shadow-red-500/[0.5]',
'-shadow-red-500/[50%]',
'-shadow-current',
'-shadow-current/50',
'-shadow-current/[0.5]',
'-shadow-current/[50%]',
'-shadow-inherit',
'-shadow-transparent',
'-shadow-[#0088cc]',
'-shadow-[#0088cc]/50',
'-shadow-[#0088cc]/[0.5]',
'-shadow-[#0088cc]/[50%]',
'-shadow-[var(--value)]',
'shadow/foo',
'shadow-sm/foo',
'shadow-[10px_10px]/foo',
'shadow-[#0088cc]/foo',
],
css`
@theme {
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
@tailwind utilities;
`,
),
).toEqual('')
})
@ -28216,25 +28263,37 @@ test('inset-shadow', async () => {
"
`)
expect(
await run([
'-inset-shadow-sm',
'-inset-shadow-none',
'-inset-shadow-red-500',
'-inset-shadow-red-500/50',
'-inset-shadow-red-500/[0.5]',
'-inset-shadow-red-500/[50%]',
'-inset-shadow-current',
'-inset-shadow-current/50',
'-inset-shadow-current/[0.5]',
'-inset-shadow-current/[50%]',
'-inset-shadow-inherit',
'-inset-shadow-transparent',
'-inset-shadow-[#0088cc]',
'-inset-shadow-[#0088cc]/50',
'-inset-shadow-[#0088cc]/[0.5]',
'-inset-shadow-[#0088cc]/[50%]',
'-inset-shadow-[var(--value)]',
]),
await run(
[
'-inset-shadow-sm',
'-inset-shadow-none',
'-inset-shadow-red-500',
'-inset-shadow-red-500/50',
'-inset-shadow-red-500/[0.5]',
'-inset-shadow-red-500/[50%]',
'-inset-shadow-current',
'-inset-shadow-current/50',
'-inset-shadow-current/[0.5]',
'-inset-shadow-current/[50%]',
'-inset-shadow-inherit',
'-inset-shadow-transparent',
'-inset-shadow-[#0088cc]',
'-inset-shadow-[#0088cc]/50',
'-inset-shadow-[#0088cc]/[0.5]',
'-inset-shadow-[#0088cc]/[50%]',
'-inset-shadow-[var(--value)]',
'inset-shadow/foo',
'inset-shadow-sm/foo',
'inset-shadow-[10px_10px]/foo',
],
css`
@theme {
--inset-shadow: inset 0 1px 3px 0 rgb(0 0 0 / 0.1);
--inset-shadow-sm: inset 0 1px 1px rgb(0 0 0 / 0.05);
}
@tailwind utilities;
`,
),
).toEqual('')
})

View file

@ -421,6 +421,13 @@ export function createUtilities(theme: Theme) {
desc.themeKeys ?? [],
)
// If the theme value resolved without consuming the modifier, then
// the modifier would be silently ignored and `w-4` and `w-4/foo`
// would generate the same CSS.
//
// The modifier would be consumed if it's a fraction, e.g.: `w-1/2`
if (value !== null && candidate.modifier && !candidate.value.fraction) return
// Automatically handle things like `w-1/2` without requiring `1/2` to
// exist as a theme value.
if (value === null && desc.supportsFractions && candidate.value.fraction) {
@ -3888,6 +3895,10 @@ export function createUtilities(theme: Theme) {
}
{
// A modifier is only valid as the opacity of a stroke color. The value
// did not resolve to a color, so a modifier would be silently ignored.
if (candidate.modifier) return
let value = theme.resolve(candidate.value.value, ['--stroke-width'])
if (value) {
return [decl('stroke-width', value)]
@ -4628,6 +4639,7 @@ export function createUtilities(theme: Theme) {
let value = theme.get(['--drop-shadow'])
let resolved = theme.resolve(null, ['--drop-shadow'])
if (value === null || resolved === null) return
if (candidate.modifier && !alpha) return
return [
filterProperties(),
@ -5436,6 +5448,7 @@ export function createUtilities(theme: Theme) {
if (!candidate.value) {
let value = theme.get(['--text-shadow'])
if (value === null) return
if (candidate.modifier && !alpha) return
return [
textShadowProperties(),
@ -5463,6 +5476,8 @@ export function createUtilities(theme: Theme) {
]
}
default: {
if (candidate.modifier && !alpha) return
return [
textShadowProperties(),
decl('--tw-text-shadow-alpha', alpha),
@ -5491,6 +5506,8 @@ export function createUtilities(theme: Theme) {
{
let value = theme.get([`--text-shadow-${candidate.value.value}`])
if (value) {
if (candidate.modifier && !alpha) return
return [
textShadowProperties(),
decl('--tw-text-shadow-alpha', alpha),
@ -5582,6 +5599,7 @@ export function createUtilities(theme: Theme) {
if (!candidate.value) {
let value = theme.get(['--shadow'])
if (value === null) return
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
@ -5611,6 +5629,8 @@ export function createUtilities(theme: Theme) {
]
}
default: {
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
decl('--tw-shadow-alpha', alpha),
@ -5644,6 +5664,8 @@ export function createUtilities(theme: Theme) {
{
let value = theme.get([`--shadow-${candidate.value.value}`])
if (value) {
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
decl('--tw-shadow-alpha', alpha),
@ -5708,6 +5730,7 @@ export function createUtilities(theme: Theme) {
if (!candidate.value) {
let value = theme.get(['--inset-shadow'])
if (value === null) return
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
@ -5737,6 +5760,8 @@ export function createUtilities(theme: Theme) {
]
}
default: {
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
decl('--tw-inset-shadow-alpha', alpha),
@ -5772,6 +5797,8 @@ export function createUtilities(theme: Theme) {
let value = theme.get([`--inset-shadow-${candidate.value.value}`])
if (value) {
if (candidate.modifier && !alpha) return
return [
boxShadowProperties(),
decl('--tw-inset-shadow-alpha', alpha),