From 7d51e38d8c4a5cba20face7384be9629c9dcc3c8 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Fri, 14 Feb 2025 18:14:14 +0100 Subject: [PATCH] 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: ![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 https://github.com/tailwindlabs/tailwindcss-typography/issues/383: Screenshot 2025-02-14 at 13 21 22 --- CHANGELOG.md | 1 + .../tailwindcss/src/compat/plugin-api.test.ts | 132 ++++++++++++++++++ packages/tailwindcss/src/compat/plugin-api.ts | 24 +++- 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22868c5c4..c0022128f 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)) - Upgrade: Ensure a `darkMode` JS config setting with block syntax converts to use `@slot` ([#16507](https://github.com/tailwindlabs/tailwindcss/pull/16507)) diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index 9eb65ccb9..ff9a016e3 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 8db4d6897..9a2f5d117 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) + } + }) +}