From 40aca13fb0261e042aeb4a42921145a35b74812e Mon Sep 17 00:00:00 2001 From: shadcn Date: Sat, 21 Feb 2026 21:41:01 +0400 Subject: [PATCH] fix: handling of apply directive inside utility --- .../shadcn/src/utils/updaters/update-css.ts | 54 ++++++++- .../test/utils/updaters/update-css.test.ts | 108 ++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/packages/shadcn/src/utils/updaters/update-css.ts b/packages/shadcn/src/utils/updaters/update-css.ts index 37243225f5..b474891cfc 100644 --- a/packages/shadcn/src/utils/updaters/update-css.ts +++ b/packages/shadcn/src/utils/updaters/update-css.ts @@ -317,7 +317,7 @@ function updateCssPlugin(css: z.infer) { postcss.comment({ text: "---break---" }) ) - // Add declarations with their values preserved + // Add declarations with their values preserved. if (typeof properties === "object") { for (const [prop, value] of Object.entries(properties)) { if (typeof value === "string") { @@ -327,13 +327,38 @@ function updateCssPlugin(css: z.infer) { raws: { semicolon: true, before: "\n " }, }) atRule.append(decl) + } else if ( + prop.startsWith("@") && + typeof value === "object" && + value !== null && + Object.keys(value as Record).length === 0 + ) { + // Handle at-rules with no body (e.g., @apply). + const atRuleMatch = prop.match(/@([a-zA-Z-]+)\s*(.*)/) + if (atRuleMatch) { + const [, atRuleName, atRuleParams] = atRuleMatch + const existingAtRule = atRule.nodes?.find( + (node): node is AtRule => + node.type === "atrule" && + node.name === atRuleName && + node.params === atRuleParams + ) + if (!existingAtRule) { + const newAtRule = postcss.atRule({ + name: atRuleName, + params: atRuleParams, + raws: { semicolon: true, before: "\n " }, + }) + atRule.append(newAtRule) + } + } } else if (typeof value === "object") { processRule(atRule, prop, value) } } } } else { - // Update existing utility class + // Update existing utility class. if (typeof properties === "object") { for (const [prop, value] of Object.entries(properties)) { if (typeof value === "string") { @@ -351,6 +376,31 @@ function updateCssPlugin(css: z.infer) { existingDecl ? existingDecl.replaceWith(decl) : utilityAtRule.append(decl) + } else if ( + prop.startsWith("@") && + typeof value === "object" && + value !== null && + Object.keys(value as Record).length === 0 + ) { + // Handle at-rules with no body (e.g., @apply). + const atRuleMatch = prop.match(/@([a-zA-Z-]+)\s*(.*)/) + if (atRuleMatch) { + const [, atRuleName, atRuleParams] = atRuleMatch + const existingAtRule = utilityAtRule.nodes?.find( + (node): node is AtRule => + node.type === "atrule" && + node.name === atRuleName && + node.params === atRuleParams + ) + if (!existingAtRule) { + const newAtRule = postcss.atRule({ + name: atRuleName, + params: atRuleParams, + raws: { semicolon: true, before: "\n " }, + }) + utilityAtRule.append(newAtRule) + } + } } else if (typeof value === "object") { processRule(utilityAtRule, prop, value) } diff --git a/packages/shadcn/test/utils/updaters/update-css.test.ts b/packages/shadcn/test/utils/updaters/update-css.test.ts index 3470409922..e168f0dfeb 100644 --- a/packages/shadcn/test/utils/updaters/update-css.test.ts +++ b/packages/shadcn/test/utils/updaters/update-css.test.ts @@ -929,6 +929,114 @@ describe("transformCss", () => { `) }) + test("should handle @apply inside @utility", async () => { + const input = `@import "tailwindcss";` + + const result = await transformCss(input, { + "@utility custom-btn": { + "@apply px-4 py-2 rounded-md": {}, + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @utility custom-btn { + @apply px-4 py-2 rounded-md; + }" + `) + }) + + test("should handle @apply mixed with declarations inside @utility", async () => { + const input = `@import "tailwindcss";` + + const result = await transformCss(input, { + "@utility custom-card": { + "@apply bg-white shadow-md": {}, + "border-radius": "0.5rem", + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @utility custom-card { + @apply bg-white shadow-md; + border-radius: 0.5rem; + }" + `) + }) + + test("should handle multiple @apply inside @utility", async () => { + const input = `@import "tailwindcss";` + + const result = await transformCss(input, { + "@utility custom-input": { + "@apply border border-gray-300 rounded-md": {}, + "@apply focus:ring-2 focus:ring-blue-500": {}, + padding: "0.5rem", + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @utility custom-input { + @apply border border-gray-300 rounded-md; + @apply focus:ring-2 focus:ring-blue-500; + padding: 0.5rem; + }" + `) + }) + + test("should add @apply to existing @utility", async () => { + const input = `@import "tailwindcss"; + +@utility custom-alert { + font-size: 1rem; +}` + + const result = await transformCss(input, { + "@utility custom-alert": { + "@apply font-bold text-red-500": {}, + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @utility custom-alert { + font-size: 1rem; + @apply font-bold text-red-500; + }" + `) + }) + + test("should not duplicate @apply inside existing @utility", async () => { + const input = `@import "tailwindcss"; + +@utility custom-badge { + @apply inline-flex items-center; + font-size: 0.75rem; +}` + + const result = await transformCss(input, { + "@utility custom-badge": { + "@apply inline-flex items-center": {}, + "font-size": "0.875rem", + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @utility custom-badge { + @apply inline-flex items-center; + font-size: 0.875rem; + }" + `) + }) + test("should replace existing keyframes instead of duplicating", async () => { const input = `@import "tailwindcss";