diff --git a/CHANGELOG.md b/CHANGELOG.md index cce3e765b638..4d3113e54b2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) ## [4.0.6] - 2025-02-10 diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index 9eb65ccb954a..ff9a016e391a 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -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()', () => { diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 8db4d68973fb..9a2f5d117e5c 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -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 + +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) + } + }) +}