diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 05e4213c0f6b..7cae2182a884 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -308,6 +308,165 @@ describe('@apply', () => { }" `) }) + + it('should be possible to apply user-defined CSS', () => { + expect( + compileCss(css` + @theme { + --spacing-2: 0.5rem; + --spacing-3: 0.75rem; + --color-red-600: #e53e3e; + } + + .btn { + @apply py-2 px-3; + } + + .btn-red { + @apply btn bg-red-600; + } + `), + ).toMatchInlineSnapshot(` + ":root { + --spacing-2: .5rem; + --spacing-3: .75rem; + --color-red-600: #e53e3e; + } + + .btn { + padding-top: var(--spacing-2, .5rem); + padding-bottom: var(--spacing-2, .5rem); + padding-left: var(--spacing-3, .75rem); + padding-right: var(--spacing-3, .75rem); + } + + .btn-red { + padding-top: var(--spacing-2, .5rem); + padding-bottom: var(--spacing-2, .5rem); + padding-left: var(--spacing-3, .75rem); + padding-right: var(--spacing-3, .75rem); + background-color: var(--color-red-600, #e53e3e); + }" + `) + }) + + it('should apply user-defined CSS that happens to be a utility class', () => { + expect( + compileCss(css` + .flex { + --display-mode: flex; + } + + .example { + @apply flex; + } + `), + ).toMatchInlineSnapshot(` + ".flex { + --display-mode: flex; + } + + .example { + --display-mode: flex; + display: flex; + }" + `) + }) + + it('should apply user-defined CSS that is defined after where the `@apply` is used', () => { + expect( + compileCss(css` + .example { + @apply foo; + } + + .foo { + color: red; + } + `), + ).toMatchInlineSnapshot(` + ".example, .foo { + color: red; + }" + `) + }) + + it('should apply user-defined CSS that is defined multiple times', () => { + expect( + compileCss(css` + .foo { + color: red; + } + + .example { + @apply foo; + } + + .foo { + background-color: blue; + } + `), + ).toMatchInlineSnapshot(` + ".foo { + color: red; + } + + .example { + color: red; + background-color: #00f; + } + + .foo { + background-color: #00f; + }" + `) + }) + + it('should error when circular @apply is used', () => { + expect(() => + compileCss(css` + .foo { + @apply bar; + } + + .bar { + @apply baz; + } + + .baz { + @apply foo; + } + `), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: You cannot \`@apply\` the \`foo\` utility here because it creates a circular dependency.]`, + ) + }) + + it('should error when circular @apply is used but nested', () => { + expect(() => + compileCss(css` + .foo { + &:hover { + @apply bar; + } + } + + .bar { + &:hover { + @apply baz; + } + } + + .baz { + &:hover { + @apply foo; + } + } + `), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: You cannot \`@apply\` the \`foo\` utility here because it creates a circular dependency.]`, + ) + }) }) describe('arbitrary variants', () => { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 34a095584944..1e7288b3b948 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -4,6 +4,7 @@ import { compileCandidates } from './compile' import * as CSS from './css-parser' import { buildDesignSystem } from './design-system' import { Theme } from './theme' +import { isSimpleClassSelector } from './utils/is-simple-class-selector' export function compile(css: string): { build(candidates: string[]): string @@ -20,6 +21,10 @@ export function compile(css: string): { invalidCandidates.add(candidate) } + // Track `@apply` information + let containsAtApply = css.includes('@apply') + let userDefinedApplyables = new Map() + // Find all `@theme` declarations let theme = new Theme() let firstThemeRule: Rule | null = null @@ -28,6 +33,33 @@ export function compile(css: string): { walk(ast, (node, { replaceWith }) => { if (node.kind !== 'rule') return + // Track all user-defined classes for `@apply` support + if ( + containsAtApply && + // Verify that it is a valid applyable-class. An applyable class is a + // class that is a very simple selector, like `.foo` or `.bar`, but doesn't + // contain any spaces, combinators, pseudo-selectors, pseudo-elements, or + // attribute selectors. + node.selector[0] === '.' && + isSimpleClassSelector(node.selector) + ) { + // Convert the class `.foo` into a candidate `foo` + let candidate = node.selector.slice(1) + + // It could be that multiple definitions exist for the same class, so we + // need to track all of them. + let nodes = userDefinedApplyables.get(candidate) ?? [] + + // Add all children of the current rule to the list of nodes for the + // current candidate. + for (let child of node.nodes) { + nodes.push(child) + } + + // Store the list of nodes for the current candidate + userDefinedApplyables.set(candidate, nodes) + } + // Drop instances of `@media reference` // // We support `@import "tailwindcss/theme" reference` as a way to import an external theme file @@ -143,40 +175,98 @@ export function compile(css: string): { }) // Replace `@apply` rules with the actual utility classes. - if (css.includes('@apply')) { - walk(ast, (node, { replaceWith }) => { - if (node.kind === 'rule' && node.selector[0] === '@' && node.selector.startsWith('@apply')) { - let candidates = node.selector - .slice(7 /* Ignore `@apply ` when parsing the selector */) - .trim() - .split(/\s+/g) - - // Replace the `@apply` rule with the actual utility classes - { - // Parse the candidates to an AST that we can replace the `@apply` rule with. - let candidateAst = compileCandidates(candidates, designSystem, { - onInvalidCandidate: (candidate) => { - throw new Error(`Cannot apply unknown utility class: ${candidate}`) - }, - }).astNodes - - // Collect the nodes to insert in place of the `@apply` rule. When a - // rule was used, we want to insert its children instead of the rule - // because we don't want the wrapping selector. - let newNodes: AstNode[] = [] - for (let candidateNode of candidateAst) { - if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') { - for (let child of candidateNode.nodes) { - newNodes.push(child) + if (containsAtApply) { + walk(ast, (root) => { + if (root.kind !== 'rule') return WalkAction.Continue + + // It's possible to `@apply` user-defined classes. We need to make sure + // that we never run into a situation where we are eventually applying + // the same class that we are currently processing otherwise we will end + // up in an infinite loop (circular dependency). + // + // This means that we need to track the current node as a candidate and + // error when we encounter it again. + let rootAsCandidate = root.selector.slice(1) + + walk(root.nodes, (node, { replaceWith }) => { + if ( + node.kind === 'rule' && + node.selector[0] === '@' && + node.selector.startsWith('@apply') + ) { + let candidates = node.selector + .slice(7 /* Ignore `@apply ` when parsing the selector */) + .trim() + .split(/\s+/g) + + // Replace the `@apply` rule with the actual utility classes + { + let newNodes: AstNode[] = [] + + // Collect all user-defined classes for the current candidates that + // we need to apply. + for (let candidate of candidates) { + // If the candidate is the same as the current node we are + // processing, we have a circular dependency. + if (candidate === rootAsCandidate) { + throw new Error( + `You cannot \`@apply\` the \`${candidate}\` utility here because it creates a circular dependency.`, + ) + } + + let nodes = userDefinedApplyables.get(candidate) + if (!nodes) continue + + for (let child of nodes) { + newNodes.push(structuredClone(child)) + } + } + + // Parse the candidates to an AST that we can replace the `@apply` + // rule with. + let candidateAst = compileCandidates(candidates, designSystem, { + onInvalidCandidate: (candidate) => { + // We must pass in user-defined classes and then filter them out + // here because, while they are usually not known utilities, the + // user can define a class that happens to *also* be a known + // utility. + // + // For example, given the following, `flex` counts as both a + // user-defined class and a known utility: + // + // ```css + // .flex { + // --display-mode: flex; + // } + // ``` + // + // When the user then uses `@apply flex`, we want to both apply + // the user-defined class and the utility class. + if (userDefinedApplyables.has(candidate)) return + + throw new Error(`Cannot apply unknown utility class: ${candidate}`) + }, + }).astNodes + + // Collect the nodes to insert in place of the `@apply` rule. When a + // rule was used, we want to insert its children instead of the rule + // because we don't want the wrapping selector. + for (let candidateNode of candidateAst) { + if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') { + for (let child of candidateNode.nodes) { + newNodes.push(child) + } + } else { + newNodes.push(candidateNode) } - } else { - newNodes.push(candidateNode) } - } - replaceWith(newNodes) + replaceWith(newNodes) + } } - } + }) + + return WalkAction.Skip }) } diff --git a/packages/tailwindcss/src/utils/is-simple-class-selector.test.ts b/packages/tailwindcss/src/utils/is-simple-class-selector.test.ts new file mode 100644 index 000000000000..8dd854c23144 --- /dev/null +++ b/packages/tailwindcss/src/utils/is-simple-class-selector.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from 'vitest' +import { isSimpleClassSelector } from './is-simple-class-selector' + +it.each([ + // Simple class selector + ['.foo', true], + + // Class selectors with escaped characters + ['.w-\\[123px\\]', true], + ['.content-\\[\\+\\>\\~\\*\\]', true], + + // ID selector + ['#foo', false], + ['.foo#foo', false], + + // Element selector + ['h1', false], + ['h1.foo', false], + + // Attribute selector + ['[data-foo]', false], + ['.foo[data-foo]', false], + ['[data-foo].foo', false], + + // Pseudo-class selector + ['.foo:hover', false], + + // Additional class selector + ['.foo.bar', false], + + // Combinator + ['.foo>.bar', false], + ['.foo+.bar', false], + ['.foo~.bar', false], + ['.foo .bar', false], + + // Selector list + ['.foo, .bar', false], + ['.foo,.bar', false], +])('should validate %s', (selector, expected) => { + expect(isSimpleClassSelector(selector)).toBe(expected) +}) diff --git a/packages/tailwindcss/src/utils/is-simple-class-selector.ts b/packages/tailwindcss/src/utils/is-simple-class-selector.ts new file mode 100644 index 000000000000..6f43fb61c3c6 --- /dev/null +++ b/packages/tailwindcss/src/utils/is-simple-class-selector.ts @@ -0,0 +1,33 @@ +/** + * Check if a selector is a simple class selector. + * + * A simple class selector is a class selector that doesn't contain any other + * selector types, such as ID selectors, element selectors, attribute selectors, + * pseudo-classes, combinators, or selector lists. + */ +export function isSimpleClassSelector(selector: string): boolean { + // The selector must start with a dot, otherwise it's not a class selector. + if (selector[0] !== '.') return false + + for (let i = 1; i < selector.length; i++) { + switch (selector[i]) { + // The character is escaped, skip the next character + case '\\': + i += 1 + continue + + case ' ': // Descendat combinator + case '.': // Class selector + case '#': // ID selector + case '[': // Attribute selector + case ':': // Pseudo-classes and pseudo-elements + case '>': // Child combinator + case '+': // Next-sibling combinator + case '~': // Subsequent-sibling combinator + case ',': // Selector list + return false + } + } + + return true +}