diff --git a/CHANGELOG.md b/CHANGELOG.md index 6991c744c720..f2fffa425c31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgrade: don’t migrate inline `style` attributes ([#19918](https://github.com/tailwindlabs/tailwindcss/pull/19918)) - Allow multiple `@utility` definitions with the same name but different value types ([#19777](https://github.com/tailwindlabs/tailwindcss/pull/19777)) - Export missing `PluginWithConfig` type from `tailwindcss/plugin` to fix errors when inferring plugin config types ([#19707](https://github.com/tailwindlabs/tailwindcss/pull/19707)) +- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427)) - Ensure `start` and `end` legacy utilities without values do not generate CSS ([#20003](https://github.com/tailwindlabs/tailwindcss/pull/20003)) ## [4.2.4] - 2026-04-21 diff --git a/packages/tailwindcss/src/apply.ts b/packages/tailwindcss/src/apply.ts index c7bcc5e28801..2d5e5b847bbc 100644 --- a/packages/tailwindcss/src/apply.ts +++ b/packages/tailwindcss/src/apply.ts @@ -163,13 +163,52 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) { let parts = child.params.split(/(\s+)/g) let candidateOffsets: Record = {} + let normalIdents: string[] = [] + let dashedIdents: string[] = [] let offset = 0 for (let [idx, part] of parts.entries()) { - if (idx % 2 === 0) candidateOffsets[part] = offset + if (idx % 2 === 0) { + if (part[0] === '-' && part[1] === '-') { + dashedIdents.push(part) + } else { + normalIdents.push(part) + } + + candidateOffsets[part] = offset + } + offset += part.length } + if (dashedIdents.length) { + // If we have an `@apply` that only consists of dashed idents then the + // user is intending to use a CSS mixin: + // https://drafts.csswg.org/css-mixins-1/#apply-rule + // + // These are not considered utilities and need to be emitted literally. + if (normalIdents.length === 0) return WalkAction.Skip + + // If we find a dashed ident *here* it means that someone is trying + // to use mixins and our `@apply` behavior together. + // + // This is invalid and the rules must be written separately. Let the + // user know they need to move them into a separate rule. + let list = dashedIdents.join(' ') + + throw new Error( + `You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply ${list}\` into a separate rule.`, + ) + } + + let hasBody = child.nodes.length > 0 + + if (hasBody && normalIdents.length) { + let list = normalIdents.join(' ') + + throw new Error(`The rule \`@apply ${list}\` must not have a body.`) + } + // Replace the `@apply` rule with the actual utility classes { // Parse the candidates to an AST that we can replace the `@apply` rule diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 77fa742e90c6..eea9eb5c0351 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -403,7 +403,8 @@ export function optimizeAst( copy.name === '@charset' || copy.name === '@custom-media' || copy.name === '@namespace' || - copy.name === '@import' + copy.name === '@import' || + copy.name === '@apply' ) { parent.push(copy) } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index e761d763e5e3..11426d7e10ac 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -856,6 +856,95 @@ describe('@apply', () => { }" `) }) + + it('should be usable with CSS mixins', async () => { + let input = css` + .foo { + /* Utility usage */ + @apply underline; + + /* CSS mixin usage */ + @apply --my-mixin-1; + @apply --my-mixin-1(); + @apply --my-mixin-1 --my-mixin-2; + @apply --my-mixin-1() --my-mixin-2(); + @apply --my-mixin-3 { + color: red; + } + } + ` + + let compiler = await compile(input) + expect(compiler.build([])).toMatchInlineSnapshot(` + ".foo { + text-decoration-line: underline; + @apply --my-mixin-1; + @apply --my-mixin-1(); + @apply --my-mixin-1 --my-mixin-2; + @apply --my-mixin-1() --my-mixin-2(); + @apply --my-mixin-3 { + color: red; + } + } + " + `) + + // TODO: This output is currently broken because Lightning CSS doesn't + // handle this case correctly yet + expect(await compileCss(input)).toMatchInlineSnapshot(` + ".foo { + text-decoration-line: underline; + } + + @apply --my-mixin-1; + + @apply --my-mixin-1(); + + @apply --my-mixin-1 --my-mixin-2; + + @apply --my-mixin-1() --my-mixin-2(); + + @apply --my-mixin-3 { + color: red; + }" + `) + }) + + it('should error when trying to use mixins and utilities together', async () => { + await expect( + compile(css` + .foo { + @apply underline --my-mixin-1; + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply --my-mixin-1\` into a separate rule.]`, + ) + + await expect( + compile(css` + .foo { + @apply --my-mixin-1 underline; + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply --my-mixin-1\` into a separate rule.]`, + ) + }) + + it('should error when used with a body', async () => { + await expect( + compile(css` + .foo { + @apply underline { + color: red; + } + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The rule \`@apply underline\` must not have a body.]`, + ) + }) }) describe('arbitrary variants', () => { diff --git a/packages/tailwindcss/src/source-maps/source-map.test.ts b/packages/tailwindcss/src/source-maps/source-map.test.ts index 5fe0c0e5bede..25399e843898 100644 --- a/packages/tailwindcss/src/source-maps/source-map.test.ts +++ b/packages/tailwindcss/src/source-maps/source-map.test.ts @@ -677,6 +677,7 @@ test('@apply generates source maps', async ({ expect }) => { color: blue; @apply text-[#000] hover:text-[#f00]; @apply underline; + @apply --my-mixin-1 --my-mixin-2(); color: red; } `, @@ -686,29 +687,31 @@ test('@apply generates source maps', async ({ expect }) => { expect(annotations).toMatchInlineSnapshot(` " - output.css | input.css - | - 1 .foo { | 1 .foo { - ^^^^^ A @ 1:0-5 | ^^^^^ A @ 1:0-5 - 2 color: blue; | 2 color: blue; - ^^^^^^^^^^^ B @ 2:2-13 | ^^^^^^^^^^^ B @ 2:2-13 - 3 color: #000; | 3 @apply text-[#000] hover:text-[#f00]; - ^^^^^^^^^^^ C @ 3:2-13 | ^^^^^^^^^^^ C @ 3:9-20 - 4 &:hover { | 3 @apply text-[#000] hover:text-[#f00]; - ^^^^^^^^ D @ 4:2-10 | ^^^^^^^^^^^^^^^^^ D @ 3:21-38 - 5 @media (hover: hover) { | - ^^^^^^^^^^^^^^^^^^^^^^ D @ 5:4-26 | - 6 color: #f00; | - ^^^^^^^^^^^ D @ 6:6-17 | - 7 } | - 8 } | - 9 text-decoration-line: underline; | 4 @apply underline; - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E @ 9:2-33 | ^^^^^^^^^ E @ 4:9-18 - 10 color: red; | 5 color: red; - ^^^^^^^^^^ F @ 10:2-12 | ^^^^^^^^^^ F @ 5:2-12 - | 6 } - 11 } | - 12 | + output.css | input.css + | + 1 .foo { | 1 .foo { + ^^^^^ A @ 1:0-5 | ^^^^^ A @ 1:0-5 + 2 color: blue; | 2 color: blue; + ^^^^^^^^^^^ B @ 2:2-13 | ^^^^^^^^^^^ B @ 2:2-13 + 3 color: #000; | 3 @apply text-[#000] hover:text-[#f00]; + ^^^^^^^^^^^ C @ 3:2-13 | ^^^^^^^^^^^ C @ 3:9-20 + 4 &:hover { | 3 @apply text-[#000] hover:text-[#f00]; + ^^^^^^^^ D @ 4:2-10 | ^^^^^^^^^^^^^^^^^ D @ 3:21-38 + 5 @media (hover: hover) { | + ^^^^^^^^^^^^^^^^^^^^^^ D @ 5:4-26 | + 6 color: #f00; | + ^^^^^^^^^^^ D @ 6:6-17 | + 7 } | + 8 } | + 9 text-decoration-line: underline; | 4 @apply underline; + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E @ 9:2-33 | ^^^^^^^^^ E @ 4:9-18 + 10 @apply --my-mixin-1 --my-mixin-2(); | 5 @apply --my-mixin-1 --my-mixin-2(); + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ F @ 10:2-36 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ F @ 5:2-36 + 11 color: red; | 6 color: red; + ^^^^^^^^^^ G @ 11:2-12 | ^^^^^^^^^^ G @ 6:2-12 + | 7 } + 12 } | + 13 | " `) })