Skip to content

Commit 7d51e38

Browse files
Fix plugins with nested rules refering to the utility name (#16539)
Closes tailwindlabs/tailwindcss-typography#383 This PR fixes an issue that happened when JavaScript plugins create a nested rule that references to the utility name. The previous behavior looked like this: ![image](https://github.com/user-attachments/assets/93ff869d-c95b-49d0-879c-7c20a852fa09) I was able to come up with an approach that can be fixed entirely in the compat layer by leveraging the `raw` field on the candidate. ## Test plan - Added unit tests - Verified with the reproduction from tailwindlabs/tailwindcss-typography#383: <img width="1458" alt="Screenshot 2025-02-14 at 13 21 22" src="https://github.com/user-attachments/assets/50544abc-e98f-48cd-b78c-ad7697387dd8" />
1 parent a1e083a commit 7d51e38

File tree

3 files changed

+156
-1
lines changed

3 files changed

+156
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- Ensure `--default-outline-width` can be used to change the `outline-width` value of the `outline` utility
2121
- Ensure drop shadow utilities don't inherit unexpectedly ([#16471](https://github.com/tailwindlabs/tailwindcss/pull/16471))
2222
- Export backwards compatible config and plugin types from `tailwindcss/plugin` ([#16505](https://github.com/tailwindlabs/tailwindcss/pull/16505))
23+
- Ensure JavaScript plugins that emit nested rules referencing to the utility name work as expected ([#16539](https://github.com/tailwindlabs/tailwindcss/pull/16539))
2324
- Upgrade: Report errors when updating dependencies ([#16504](https://github.com/tailwindlabs/tailwindcss/pull/16504))
2425
- Upgrade: Ensure a `darkMode` JS config setting with block syntax converts to use `@slot` ([#16507](https://github.com/tailwindlabs/tailwindcss/pull/16507))
2526

packages/tailwindcss/src/compat/plugin-api.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3171,6 +3171,68 @@ describe('addUtilities()', () => {
31713171
`,
31723172
)
31733173
})
3174+
3175+
test('replaces the class name with variants in nested selectors', async () => {
3176+
let compiled = await compile(
3177+
css`
3178+
@plugin "my-plugin";
3179+
@theme {
3180+
--breakpoint-md: 768px;
3181+
}
3182+
@tailwind utilities;
3183+
`,
3184+
{
3185+
async loadModule(id, base) {
3186+
return {
3187+
base,
3188+
module: ({ addUtilities }: PluginAPI) => {
3189+
addUtilities({
3190+
'.foo': {
3191+
':where(.foo > :first-child)': {
3192+
color: 'red',
3193+
},
3194+
},
3195+
})
3196+
},
3197+
}
3198+
},
3199+
},
3200+
)
3201+
3202+
expect(compiled.build(['foo', 'md:foo', 'not-hover:md:foo']).trim()).toMatchInlineSnapshot(`
3203+
":root, :host {
3204+
--breakpoint-md: 768px;
3205+
}
3206+
.foo {
3207+
:where(.foo > :first-child) {
3208+
color: red;
3209+
}
3210+
}
3211+
.md\\:foo {
3212+
@media (width >= 768px) {
3213+
:where(.md\\:foo > :first-child) {
3214+
color: red;
3215+
}
3216+
}
3217+
}
3218+
.not-hover\\:md\\:foo {
3219+
&:not(*:hover) {
3220+
@media (width >= 768px) {
3221+
:where(.not-hover\\:md\\:foo > :first-child) {
3222+
color: red;
3223+
}
3224+
}
3225+
}
3226+
@media not (hover: hover) {
3227+
@media (width >= 768px) {
3228+
:where(.not-hover\\:md\\:foo > :first-child) {
3229+
color: red;
3230+
}
3231+
}
3232+
}
3233+
}"
3234+
`)
3235+
})
31743236
})
31753237

31763238
describe('matchUtilities()', () => {
@@ -3981,6 +4043,76 @@ describe('matchUtilities()', () => {
39814043
)
39824044
}).rejects.toThrowError(/invalid utility name/)
39834045
})
4046+
4047+
test('replaces the class name with variants in nested selectors', async () => {
4048+
let compiled = await compile(
4049+
css`
4050+
@plugin "my-plugin";
4051+
@theme {
4052+
--breakpoint-md: 768px;
4053+
}
4054+
@tailwind utilities;
4055+
`,
4056+
{
4057+
async loadModule(base) {
4058+
return {
4059+
base,
4060+
module: ({ matchUtilities }: PluginAPI) => {
4061+
matchUtilities(
4062+
{
4063+
foo: (value) => ({
4064+
':where(.foo > :first-child)': {
4065+
color: value,
4066+
},
4067+
}),
4068+
},
4069+
{
4070+
values: {
4071+
red: 'red',
4072+
},
4073+
},
4074+
)
4075+
},
4076+
}
4077+
},
4078+
},
4079+
)
4080+
4081+
expect(compiled.build(['foo-red', 'md:foo-red', 'not-hover:md:foo-red']).trim())
4082+
.toMatchInlineSnapshot(`
4083+
":root, :host {
4084+
--breakpoint-md: 768px;
4085+
}
4086+
.foo-red {
4087+
:where(.foo-red > :first-child) {
4088+
color: red;
4089+
}
4090+
}
4091+
.md\\:foo-red {
4092+
@media (width >= 768px) {
4093+
:where(.md\\:foo-red > :first-child) {
4094+
color: red;
4095+
}
4096+
}
4097+
}
4098+
.not-hover\\:md\\:foo-red {
4099+
&:not(*:hover) {
4100+
@media (width >= 768px) {
4101+
:where(.not-hover\\:md\\:foo-red > :first-child) {
4102+
color: red;
4103+
}
4104+
}
4105+
}
4106+
@media not (hover: hover) {
4107+
@media (width >= 768px) {
4108+
:where(.not-hover\\:md\\:foo-red > :first-child) {
4109+
color: red;
4110+
}
4111+
}
4112+
}
4113+
}"
4114+
`)
4115+
})
39844116
})
39854117

39864118
describe('addComponents()', () => {

packages/tailwindcss/src/compat/plugin-api.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as CSS from '../css-parser'
77
import type { DesignSystem } from '../design-system'
88
import { withAlpha } from '../utilities'
99
import { DefaultMap } from '../utils/default-map'
10+
import { escape } from '../utils/escape'
1011
import { inferDataType } from '../utils/infer-data-type'
1112
import { segment } from '../utils/segment'
1213
import { toKeyPath } from '../utils/to-key-path'
@@ -282,8 +283,9 @@ export function buildPluginApi({
282283
})
283284
}
284285

285-
designSystem.utilities.static(className, () => {
286+
designSystem.utilities.static(className, (candidate) => {
286287
let clonedAst = structuredClone(ast)
288+
replaceNestedClassNameReferences(clonedAst, className, candidate.raw)
287289
featuresRef.current |= substituteAtApply(clonedAst, designSystem)
288290
return clonedAst
289291
})
@@ -406,6 +408,7 @@ export function buildPluginApi({
406408
}
407409

408410
let ast = objectToAst(fn(value, { modifier }))
411+
replaceNestedClassNameReferences(ast, name, candidate.raw)
409412
featuresRef.current |= substituteAtApply(ast, designSystem)
410413
return ast
411414
}
@@ -543,3 +546,22 @@ function parseVariantValue(resolved: string | string[], nodes: AstNode[]): AstNo
543546

544547
type Primitive = string | number | boolean | null
545548
export type CssPluginOptions = Record<string, Primitive | Primitive[]>
549+
550+
function replaceNestedClassNameReferences(
551+
ast: AstNode[],
552+
utilityName: string,
553+
rawCandidate: string,
554+
) {
555+
// Replace nested rules using the utility name in the selector
556+
walk(ast, (node) => {
557+
if (node.kind === 'rule') {
558+
let selectorAst = SelectorParser.parse(node.selector)
559+
SelectorParser.walk(selectorAst, (node) => {
560+
if (node.kind === 'selector' && node.value === `.${utilityName}`) {
561+
node.value = `.${escape(rawCandidate)}`
562+
}
563+
})
564+
node.selector = SelectorParser.toCss(selectorAst)
565+
}
566+
})
567+
}

0 commit comments

Comments
 (0)