Fix plugins with nested rules refering to the utility name (#16539)
Closes https://github.com/tailwindlabs/tailwindcss-typography/issues/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:  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 https://github.com/tailwindlabs/tailwindcss-typography/issues/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" />
This commit is contained in:
parent
a1e083afc5
commit
7d51e38d8c
3 changed files with 156 additions and 1 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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()', () => {
|
||||
|
|
@ -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()', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue