Skip to content

Commit

Permalink
Fix plugins with nested rules refering to the utility name (#16539)
Browse files Browse the repository at this point in the history
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"
/>
  • Loading branch information
philipp-spiess authored Feb 14, 2025
1 parent a1e083a commit 7d51e38
Show file tree
Hide file tree
Showing 3 changed files with 156 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure `--default-outline-width` can be used to change the `outline-width` value of the `outline` utility
- Ensure drop shadow utilities don't inherit unexpectedly ([#16471](https://github.com/tailwindlabs/tailwindcss/pull/16471))
- Export backwards compatible config and plugin types from `tailwindcss/plugin` ([#16505](https://github.com/tailwindlabs/tailwindcss/pull/16505))
- Ensure JavaScript plugins that emit nested rules referencing to the utility name work as expected ([#16539](https://github.com/tailwindlabs/tailwindcss/pull/16539))
- Upgrade: Report errors when updating dependencies ([#16504](https://github.com/tailwindlabs/tailwindcss/pull/16504))
- Upgrade: Ensure a `darkMode` JS config setting with block syntax converts to use `@slot` ([#16507](https://github.com/tailwindlabs/tailwindcss/pull/16507))

Expand Down
132 changes: 132 additions & 0 deletions packages/tailwindcss/src/compat/plugin-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3171,6 +3171,68 @@ describe('addUtilities()', () => {
`,
)
})

test('replaces the class name with variants in nested selectors', async () => {
let compiled = await compile(
css`
@plugin "my-plugin";
@theme {
--breakpoint-md: 768px;
}
@tailwind utilities;
`,
{
async loadModule(id, base) {
return {
base,
module: ({ addUtilities }: PluginAPI) => {
addUtilities({
'.foo': {
':where(.foo > :first-child)': {
color: 'red',
},
},
})
},
}
},
},
)

expect(compiled.build(['foo', 'md:foo', 'not-hover:md:foo']).trim()).toMatchInlineSnapshot(`
":root, :host {
--breakpoint-md: 768px;
}
.foo {
:where(.foo > :first-child) {
color: red;
}
}
.md\\:foo {
@media (width >= 768px) {
:where(.md\\:foo > :first-child) {
color: red;
}
}
}
.not-hover\\:md\\:foo {
&:not(*:hover) {
@media (width >= 768px) {
:where(.not-hover\\:md\\:foo > :first-child) {
color: red;
}
}
}
@media not (hover: hover) {
@media (width >= 768px) {
:where(.not-hover\\:md\\:foo > :first-child) {
color: red;
}
}
}
}"
`)
})
})

describe('matchUtilities()', () => {
Expand Down Expand Up @@ -3981,6 +4043,76 @@ describe('matchUtilities()', () => {
)
}).rejects.toThrowError(/invalid utility name/)
})

test('replaces the class name with variants in nested selectors', async () => {
let compiled = await compile(
css`
@plugin "my-plugin";
@theme {
--breakpoint-md: 768px;
}
@tailwind utilities;
`,
{
async loadModule(base) {
return {
base,
module: ({ matchUtilities }: PluginAPI) => {
matchUtilities(
{
foo: (value) => ({
':where(.foo > :first-child)': {
color: value,
},
}),
},
{
values: {
red: 'red',
},
},
)
},
}
},
},
)

expect(compiled.build(['foo-red', 'md:foo-red', 'not-hover:md:foo-red']).trim())
.toMatchInlineSnapshot(`
":root, :host {
--breakpoint-md: 768px;
}
.foo-red {
:where(.foo-red > :first-child) {
color: red;
}
}
.md\\:foo-red {
@media (width >= 768px) {
:where(.md\\:foo-red > :first-child) {
color: red;
}
}
}
.not-hover\\:md\\:foo-red {
&:not(*:hover) {
@media (width >= 768px) {
:where(.not-hover\\:md\\:foo-red > :first-child) {
color: red;
}
}
}
@media not (hover: hover) {
@media (width >= 768px) {
:where(.not-hover\\:md\\:foo-red > :first-child) {
color: red;
}
}
}
}"
`)
})
})

describe('addComponents()', () => {
Expand Down
24 changes: 23 additions & 1 deletion packages/tailwindcss/src/compat/plugin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as CSS from '../css-parser'
import type { DesignSystem } from '../design-system'
import { withAlpha } from '../utilities'
import { DefaultMap } from '../utils/default-map'
import { escape } from '../utils/escape'
import { inferDataType } from '../utils/infer-data-type'
import { segment } from '../utils/segment'
import { toKeyPath } from '../utils/to-key-path'
Expand Down Expand Up @@ -282,8 +283,9 @@ export function buildPluginApi({
})
}

designSystem.utilities.static(className, () => {
designSystem.utilities.static(className, (candidate) => {
let clonedAst = structuredClone(ast)
replaceNestedClassNameReferences(clonedAst, className, candidate.raw)
featuresRef.current |= substituteAtApply(clonedAst, designSystem)
return clonedAst
})
Expand Down Expand Up @@ -406,6 +408,7 @@ export function buildPluginApi({
}

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

type Primitive = string | number | boolean | null
export type CssPluginOptions = Record<string, Primitive | Primitive[]>

function replaceNestedClassNameReferences(
ast: AstNode[],
utilityName: string,
rawCandidate: string,
) {
// Replace nested rules using the utility name in the selector
walk(ast, (node) => {
if (node.kind === 'rule') {
let selectorAst = SelectorParser.parse(node.selector)
SelectorParser.walk(selectorAst, (node) => {
if (node.kind === 'selector' && node.value === `.${utilityName}`) {
node.value = `.${escape(rawCandidate)}`
}
})
node.selector = SelectorParser.toCss(selectorAst)
}
})
}

0 comments on commit 7d51e38

Please sign in to comment.