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:
parent
021b7fe6f2
commit
7a7f386648
3 changed files with 230 additions and 143 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue