diff --git a/.changeset/tiny-moons-hang.md b/.changeset/tiny-moons-hang.md
new file mode 100644
index 0000000000..1ebdc1f862
--- /dev/null
+++ b/.changeset/tiny-moons-hang.md
@@ -0,0 +1,5 @@
+---
+"shadcn": patch
+---
+
+fix handling of nested aschild transforms
diff --git a/apps/v4/public/r/registries.json b/apps/v4/public/r/registries.json
index 880d92d3e7..81feea0656 100644
--- a/apps/v4/public/r/registries.json
+++ b/apps/v4/public/r/registries.json
@@ -77,6 +77,12 @@
"url": "https://r.assistant-ui.com/{name}.json",
"description": "Radix-style React primitives for AI chat with adapters for AI SDK, LangGraph, Mastra, and custom backends."
},
+ {
+ "name": "@tool-ui",
+ "homepage": "https://www.tool-ui.com",
+ "url": "https://www.tool-ui.com/r/{name}.json",
+ "description": "Open source React components for rendering AI tool call widgets and rich assistant outputs."
+ },
{
"name": "@better-upload",
"homepage": "https://better-upload.com",
@@ -428,8 +434,8 @@
{
"name": "@reui",
"homepage": "https://reui.io",
- "url": "https://reui.io/r/{name}.json",
- "description": "Open-source collection of UI components and animated effects built with React, Typescript, Tailwind CSS, and Motion. Pairs beautifully with shadcn/ui."
+ "url": "https://reui.io/r/{style}/{name}.json",
+ "description": "Free & open-source library of 1,000+ components and patterns to 10x your productivity in shadcn projects."
},
{
"name": "@scrollxui",
@@ -455,6 +461,12 @@
"url": "https://roiui.com/r/{name}.json",
"description": "Roi UI is a library that offers UI components and blocks built with Base UI primitives. Some blocks and components use motion (framer). Everything is open-source and will be forever."
},
+ {
+ "name": "@slide-cn",
+ "homepage": "https://slide-cn.com",
+ "url": "https://slide-cn.com/r/{name}.json",
+ "description": "A component library to build slide decks using code."
+ },
{
"name": "@satoriui",
"homepage": "https://satoriui.site",
@@ -749,4 +761,4 @@
"url": "https://sona-ui.vercel.app/r/{name}.json",
"description": "A modern UI component library built with React and TailwindCSS to help you build beautiful and accessible web applications faster."
}
-]
\ No newline at end of file
+]
diff --git a/apps/v4/public/r/styles/new-york-v4/index.json b/apps/v4/public/r/styles/new-york-v4/index.json
index ae7203d2fe..69f13cef90 100644
--- a/apps/v4/public/r/styles/new-york-v4/index.json
+++ b/apps/v4/public/r/styles/new-york-v4/index.json
@@ -16,7 +16,16 @@
"files": [],
"cssVars": {},
"css": {
- "@import \"shadcn/tailwind.css\"": {}
+ "@import \"tw-animate-css\"": {},
+ "@import \"shadcn/tailwind.css\"": {},
+ "@layer base": {
+ "*": {
+ "@apply border-border outline-ring/50": {}
+ },
+ "body": {
+ "@apply bg-background text-foreground": {}
+ }
+ }
},
"type": "registry:style"
}
\ No newline at end of file
diff --git a/apps/v4/public/r/styles/new-york-v4/registry.json b/apps/v4/public/r/styles/new-york-v4/registry.json
index 7b72e315e2..2db23814c3 100644
--- a/apps/v4/public/r/styles/new-york-v4/registry.json
+++ b/apps/v4/public/r/styles/new-york-v4/registry.json
@@ -19,7 +19,16 @@
"files": [],
"cssVars": {},
"css": {
- "@import \"shadcn/tailwind.css\"": {}
+ "@import \"tw-animate-css\"": {},
+ "@import \"shadcn/tailwind.css\"": {},
+ "@layer base": {
+ "*": {
+ "@apply border-border outline-ring/50": {}
+ },
+ "body": {
+ "@apply bg-background text-foreground": {}
+ }
+ }
},
"type": "registry:style"
},
@@ -40,7 +49,16 @@
"files": [],
"cssVars": {},
"css": {
- "@import \"shadcn/tailwind.css\"": {}
+ "@import \"tw-animate-css\"": {},
+ "@import \"shadcn/tailwind.css\"": {},
+ "@layer base": {
+ "*": {
+ "@apply border-border outline-ring/50": {}
+ },
+ "body": {
+ "@apply bg-background text-foreground": {}
+ }
+ }
},
"type": "registry:style"
},
diff --git a/apps/v4/public/r/styles/new-york-v4/style.json b/apps/v4/public/r/styles/new-york-v4/style.json
index 452a4322f8..9d8bb6576c 100644
--- a/apps/v4/public/r/styles/new-york-v4/style.json
+++ b/apps/v4/public/r/styles/new-york-v4/style.json
@@ -16,7 +16,16 @@
"files": [],
"cssVars": {},
"css": {
- "@import \"shadcn/tailwind.css\"": {}
+ "@import \"tw-animate-css\"": {},
+ "@import \"shadcn/tailwind.css\"": {},
+ "@layer base": {
+ "*": {
+ "@apply border-border outline-ring/50": {}
+ },
+ "body": {
+ "@apply bg-background text-foreground": {}
+ }
+ }
},
"type": "registry:style"
}
\ No newline at end of file
diff --git a/apps/v4/registry/directory.json b/apps/v4/registry/directory.json
index f50c7e3872..bbbc04896f 100644
--- a/apps/v4/registry/directory.json
+++ b/apps/v4/registry/directory.json
@@ -90,6 +90,13 @@
"description": "Radix-style React primitives for AI chat with adapters for AI SDK, LangGraph, Mastra, and custom backends.",
"logo": ""
},
+ {
+ "name": "@tool-ui",
+ "homepage": "https://www.tool-ui.com",
+ "url": "https://www.tool-ui.com/r/{name}.json",
+ "description": "Open source React components for rendering AI tool call widgets and rich assistant outputs.",
+ "logo": ""
+ },
{
"name": "@better-upload",
"homepage": "https://better-upload.com",
@@ -499,8 +506,8 @@
{
"name": "@reui",
"homepage": "https://reui.io",
- "url": "https://reui.io/r/{name}.json",
- "description": "Open-source collection of UI components and animated effects built with React, Typescript, Tailwind CSS, and Motion. Pairs beautifully with shadcn/ui.",
+ "url": "https://reui.io/r/{style}/{name}.json",
+ "description": "Free & open-source library of 1,000+ components and patterns to 10x your productivity in shadcn projects.",
"logo": ""
},
{
@@ -531,6 +538,13 @@
"description": "Roi UI is a library that offers UI components and blocks built with Base UI primitives. Some blocks and components use motion (framer). Everything is open-source and will be forever.",
"logo": ""
},
+ {
+ "name": "@slide-cn",
+ "homepage": "https://slide-cn.com",
+ "url": "https://slide-cn.com/r/{name}.json",
+ "description": "A component library to build slide decks using code.",
+ "logo": ""
+ },
{
"name": "@satoriui",
"homepage": "https://satoriui.site",
diff --git a/apps/v4/registry/new-york-v4/registry.ts b/apps/v4/registry/new-york-v4/registry.ts
index d9a5b99690..ce71657675 100644
--- a/apps/v4/registry/new-york-v4/registry.ts
+++ b/apps/v4/registry/new-york-v4/registry.ts
@@ -26,7 +26,16 @@ const NEW_YORK_V4_STYLE = {
devDependencies: ["tw-animate-css", "shadcn"],
registryDependencies: ["utils"],
css: {
+ '@import "tw-animate-css"': {},
'@import "shadcn/tailwind.css"': {},
+ "@layer base": {
+ "*": {
+ "@apply border-border outline-ring/50": {},
+ },
+ body: {
+ "@apply bg-background text-foreground": {},
+ },
+ },
},
cssVars: {},
files: [],
diff --git a/packages/shadcn/src/commands/add.ts b/packages/shadcn/src/commands/add.ts
index ebfb821a5d..e2cc919418 100644
--- a/packages/shadcn/src/commands/add.ts
+++ b/packages/shadcn/src/commands/add.ts
@@ -90,20 +90,19 @@ export const add = new Command()
}
let itemType: z.infer | undefined
- let shouldInstallBaseStyle = true
+ let shouldInstallStyleIndex = true
if (components.length > 0) {
const [registryItem] = await getRegistryItems([components[0]], {
config: initialConfig,
})
itemType = registryItem?.type
- shouldInstallBaseStyle =
- itemType !== "registry:theme" && itemType !== "registry:style"
+ shouldInstallStyleIndex =
+ itemType !== "registry:theme" &&
+ itemType !== "registry:style" &&
+ itemType !== "registry:base"
if (isUniversalRegistryItem(registryItem)) {
- await addComponents(components, initialConfig, {
- ...options,
- baseStyle: shouldInstallBaseStyle,
- })
+ await addComponents(components, initialConfig, options)
return
}
@@ -180,8 +179,8 @@ export const add = new Command()
isNewProject: false,
srcDir: options.srcDir,
cssVariables: options.cssVariables,
- baseStyle: shouldInstallBaseStyle,
- baseColor: shouldInstallBaseStyle ? undefined : "neutral",
+ installStyleIndex: shouldInstallStyleIndex,
+ baseColor: shouldInstallStyleIndex ? undefined : "neutral",
components: options.components,
})
initHasRun = true
@@ -216,8 +215,8 @@ export const add = new Command()
isNewProject: true,
srcDir: options.srcDir,
cssVariables: options.cssVariables,
- baseStyle: shouldInstallBaseStyle,
- baseColor: shouldInstallBaseStyle ? undefined : "neutral",
+ installStyleIndex: shouldInstallStyleIndex,
+ baseColor: shouldInstallStyleIndex ? undefined : "neutral",
components: options.components,
})
initHasRun = true
@@ -244,10 +243,7 @@ export const add = new Command()
config = updatedConfig
if (!initHasRun) {
- await addComponents(options.components, config, {
- ...options,
- baseStyle: shouldInstallBaseStyle,
- })
+ await addComponents(options.components, config, options)
}
// If we're adding a single component and it's from the v0 registry,
diff --git a/packages/shadcn/src/commands/create.ts b/packages/shadcn/src/commands/create.ts
index 6316c9383f..1df60f1d7c 100644
--- a/packages/shadcn/src/commands/create.ts
+++ b/packages/shadcn/src/commands/create.ts
@@ -205,7 +205,7 @@ export const create = new Command()
rtl: opts.rtl,
template,
baseColor,
- baseStyle: false,
+ installStyleIndex: false,
registryBaseConfig,
skipPreflight: false,
})
@@ -219,7 +219,6 @@ export const create = new Command()
components.push("direction")
}
await addComponents(components, config, {
- baseStyle: false,
silent: true,
overwrite: true,
})
diff --git a/packages/shadcn/src/commands/init.ts b/packages/shadcn/src/commands/init.ts
index 6e77070be8..dd79047107 100644
--- a/packages/shadcn/src/commands/init.ts
+++ b/packages/shadcn/src/commands/init.ts
@@ -111,7 +111,7 @@ export const initOptionsSchema = z.object({
).join("', '")}'`,
}
),
- baseStyle: z.boolean(),
+ installStyleIndex: z.boolean(),
// Config from registry:base item to merge into components.json.
registryBaseConfig: rawConfigSchema.deepPartial().optional(),
})
@@ -261,6 +261,7 @@ export const init = new Command()
isNewProject: false,
components,
...opts,
+ installStyleIndex: opts.baseStyle,
})
await loadEnvFiles(options.cwd)
@@ -332,8 +333,8 @@ export const init = new Command()
// Store config to be merged into components.json later.
options.registryBaseConfig = item.config
}
- options.baseStyle =
- item.extends === "none" ? false : options.baseStyle
+ options.installStyleIndex =
+ item.extends === "none" ? false : options.installStyleIndex
}
if (item?.type === "registry:style") {
@@ -342,14 +343,13 @@ export const init = new Command()
options.baseColor = "neutral"
// If the style extends none, we don't want to install the base style.
- options.baseStyle =
- item.extends === "none" ? false : options.baseStyle
+ options.installStyleIndex =
+ item.extends === "none" ? false : options.installStyleIndex
}
}
// If --no-base-style, we don't want to prompt for a base color either.
- // The style will extend or override it.
- if (!options.baseStyle) {
+ if (!options.installStyleIndex) {
options.baseColor = "neutral"
}
@@ -457,7 +457,7 @@ export async function runInit(
// Why index? Because when style is true, we read style from components.json and fetch that.
// i.e new-york from components.json then fetch /styles/new-york/index.
// TODO: Fix this so that we can extend any style i.e --style=new-york.
- ...(options.baseStyle ? ["index"] : []),
+ ...(options.installStyleIndex ? ["index"] : []),
...(options.components ?? []),
]
@@ -530,7 +530,6 @@ export async function runInit(
// Init will always overwrite files.
overwrite: true,
silent: options.silent,
- baseStyle: options.baseStyle,
isNewProject:
options.isNewProject || projectInfo?.framework.name === "next-app",
})
diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts
index c24d2c1924..e0cca97913 100644
--- a/packages/shadcn/src/utils/add-components.ts
+++ b/packages/shadcn/src/utils/add-components.ts
@@ -38,7 +38,6 @@ export async function addComponents(
overwrite?: boolean
silent?: boolean
isNewProject?: boolean
- baseStyle?: boolean
registryHeaders?: Record>
path?: string
}
@@ -47,7 +46,6 @@ export async function addComponents(
overwrite: false,
silent: false,
isNewProject: false,
- baseStyle: true,
...options,
}
@@ -74,11 +72,10 @@ async function addProjectComponents(
overwrite?: boolean
silent?: boolean
isNewProject?: boolean
- baseStyle?: boolean
path?: string
}
) {
- if (!options.baseStyle && !components.length) {
+ if (!components.length) {
return
}
@@ -117,7 +114,6 @@ async function addProjectComponents(
tailwindVersion,
tailwindConfig: tree.tailwind?.config,
overwriteCssVars,
- initIndex: options.baseStyle,
})
// Add CSS updater
@@ -157,11 +153,10 @@ async function addWorkspaceComponents(
silent?: boolean
isNewProject?: boolean
isRemote?: boolean
- baseStyle?: boolean
path?: string
}
) {
- if (!options.baseStyle && !components.length) {
+ if (!components.length) {
return
}
diff --git a/packages/shadcn/src/utils/transformers/transform-aschild.test.ts b/packages/shadcn/src/utils/transformers/transform-aschild.test.ts
index 5005f6e421..61fcb25e06 100644
--- a/packages/shadcn/src/utils/transformers/transform-aschild.test.ts
+++ b/packages/shadcn/src/utils/transformers/transform-aschild.test.ts
@@ -63,7 +63,7 @@ export function Component() {
})
describe("DialogTrigger with non-Button child", () => {
- test("transforms asChild to render prop with nativeButton={false}", async () => {
+ test("transforms asChild to render prop without nativeButton", async () => {
expect(
await transform(
{
@@ -86,7 +86,7 @@ export function Component() {
export function Component() {
return (
- } nativeButton={false}>Open Dialog
+ }>Open Dialog
)
}"
`)
@@ -186,7 +186,7 @@ export function Component() {
})
})
- describe("component with Link child", () => {
+ describe("Button with Link child", () => {
test("transforms asChild to render prop with nativeButton={false}", async () => {
expect(
await transform(
@@ -390,6 +390,173 @@ export function Component() {
})
})
+ describe("nested asChild", () => {
+ test("transforms inner asChild first, then outer", async () => {
+ expect(
+ await transform(
+ {
+ filename: "test.tsx",
+ raw: `import * as React from "react"
+
+export function Component() {
+ return (
+
+
+ Home
+
+
+ )
+}`,
+ config: testConfig,
+ },
+ [transformAsChild]
+ )
+ ).toMatchInlineSnapshot(`
+ "import * as React from "react"
+
+ export function Component() {
+ return (
+ } />}>Home
+ )
+ }"
+ `)
+ })
+
+ test("adds nativeButton={false} only on nested Button", async () => {
+ expect(
+ await transform(
+ {
+ filename: "test.tsx",
+ raw: `import * as React from "react"
+
+export function Component() {
+ return (
+
+
+
+ )
+}`,
+ config: testConfig,
+ },
+ [transformAsChild]
+ )
+ ).toMatchInlineSnapshot(`
+ "import * as React from "react"
+
+ export function Component() {
+ return (
+ } nativeButton={false} />}>Open
+ )
+ }"
+ `)
+ })
+
+ test("transforms nested with sibling asChild elements", async () => {
+ expect(
+ await transform(
+ {
+ filename: "test.tsx",
+ raw: `import * as React from "react"
+
+export function Component() {
+ return (
+
+
+
+ Home
+
+
+
+
+
+
+ )
+}`,
+ config: testConfig,
+ },
+ [transformAsChild]
+ )
+ ).toMatchInlineSnapshot(`
+ "import * as React from "react"
+
+ export function Component() {
+ return (
+
+ } />}>Home
+ }>Edit
+
+ )
+ }"
+ `)
+ })
+
+ test("transforms nested with self-closing inner child", async () => {
+ expect(
+ await transform(
+ {
+ filename: "test.tsx",
+ raw: `import * as React from "react"
+
+export function Component() {
+ return (
+
+
+
+
+
+ )
+}`,
+ config: testConfig,
+ },
+ [transformAsChild]
+ )
+ ).toMatchInlineSnapshot(`
+ "import * as React from "react"
+
+ export function Component() {
+ return (
+ } />}>
+ )
+ }"
+ `)
+ })
+
+ test("transforms triple-nested asChild", async () => {
+ expect(
+ await transform(
+ {
+ filename: "test.tsx",
+ raw: `import * as React from "react"
+
+export function Component() {
+ return (
+
+
+
+ Home
+
+
+
+ )
+}`,
+ config: testConfig,
+ },
+ [transformAsChild]
+ )
+ ).toMatchInlineSnapshot(`
+ "import * as React from "react"
+
+ export function Component() {
+ return (
+ } />} />}>Home
+ )
+ }"
+ `)
+ })
+ })
+
describe("idempotency", () => {
test("running twice produces same output", async () => {
const input = `import * as React from "react"
diff --git a/packages/shadcn/src/utils/transformers/transform-aschild.ts b/packages/shadcn/src/utils/transformers/transform-aschild.ts
index 543deff3c3..6be86d2d2e 100644
--- a/packages/shadcn/src/utils/transformers/transform-aschild.ts
+++ b/packages/shadcn/src/utils/transformers/transform-aschild.ts
@@ -27,118 +27,142 @@ export const transformAsChild: Transformer = async ({ sourceFile, config }) => {
return sourceFile
}
- // Collect all transformations first, then apply them in reverse order.
- // This prevents issues with invalidated nodes when modifying the tree.
- const transformations: TransformInfo[] = []
+ // Process asChild elements iteratively, starting from leaf-level elements.
+ // Each iteration transforms only elements with no asChild descendants,
+ // ensuring inner transforms complete before outer ones read the tree.
+ const MAX_ITERATIONS = 10
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
+ const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement)
- // Find all JSX elements with asChild attribute.
- const jsxElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement)
-
- for (const jsxElement of jsxElements) {
- const openingElement = jsxElement.getOpeningElement()
- const asChildAttr = openingElement.getAttribute("asChild")
-
- if (!asChildAttr) {
- continue
- }
-
- const parentTagName = openingElement.getTagNameNode().getText()
- const children = jsxElement.getJsxChildren()
-
- // Find the first JSX element child (skip whitespace/text).
- const childElement = children.find(
- (child) =>
- child.getKind() === SyntaxKind.JsxElement ||
- child.getKind() === SyntaxKind.JsxSelfClosingElement
+ // Find all JSX elements with asChild attribute.
+ const asChildElements = jsxElements.filter((el) =>
+ el.getOpeningElement().getAttribute("asChild")
)
- if (!childElement) {
- // No child element found, just remove asChild.
- asChildAttr.remove()
- continue
+ if (asChildElements.length === 0) {
+ break
}
- // Get child element info.
- let childTagName: string
- let childProps: string
- let childChildren: string
-
- if (childElement.getKind() === SyntaxKind.JsxSelfClosingElement) {
- const selfClosing = childElement.asKindOrThrow(
- SyntaxKind.JsxSelfClosingElement
+ // Filter to leaf-only: elements with no asChild descendants.
+ const leafElements = asChildElements.filter((el) => {
+ const descendants = el.getDescendantsOfKind(SyntaxKind.JsxElement)
+ return !descendants.some((d) =>
+ d.getOpeningElement().getAttribute("asChild")
)
- childTagName = selfClosing.getTagNameNode().getText()
- childProps = selfClosing
- .getAttributes()
- .map((attr) => attr.getText())
- .join(" ")
- childChildren = ""
- } else {
- const jsxChild = childElement.asKindOrThrow(SyntaxKind.JsxElement)
- const openingEl = jsxChild.getOpeningElement()
- childTagName = openingEl.getTagNameNode().getText()
- childProps = openingEl
- .getAttributes()
- .map((attr) => attr.getText())
- .join(" ")
- // Get the children's text content.
- childChildren = jsxChild
- .getJsxChildren()
- .map((c) => c.getText())
- .join("")
- }
-
- // Determine if we need nativeButton={false}.
- // Add it when the child element is a non-button element.
- const needsNativeButton =
- ELEMENTS_REQUIRING_NATIVE_BUTTON_FALSE.includes(childTagName)
-
- transformations.push({
- parentElement: jsxElement,
- parentTagName,
- childTagName,
- childProps,
- childChildren,
- needsNativeButton,
})
- }
- // Apply transformations in reverse order to preserve node validity.
- for (const info of transformations.reverse()) {
- const openingElement = info.parentElement.getOpeningElement()
- const closingElement = info.parentElement.getClosingElement()
+ // Collect all transformations first, then apply them in reverse order.
+ // This prevents issues with invalidated nodes when modifying the tree.
+ const transformations: TransformInfo[] = []
- // Get existing attributes (excluding asChild).
- const existingAttrs = openingElement
- .getAttributes()
- .filter((attr) => {
- if (attr.getKind() === SyntaxKind.JsxAttribute) {
- const jsxAttr = attr.asKindOrThrow(SyntaxKind.JsxAttribute)
- return jsxAttr.getNameNode().getText() !== "asChild"
- }
- return true
+ for (const jsxElement of leafElements) {
+ const openingElement = jsxElement.getOpeningElement()
+ const asChildAttr = openingElement.getAttribute("asChild")
+
+ if (!asChildAttr) {
+ continue
+ }
+
+ const parentTagName = openingElement.getTagNameNode().getText()
+ const children = jsxElement.getJsxChildren()
+
+ // Find the first JSX element child (skip whitespace/text).
+ const childElement = children.find(
+ (child) =>
+ child.getKind() === SyntaxKind.JsxElement ||
+ child.getKind() === SyntaxKind.JsxSelfClosingElement
+ )
+
+ if (!childElement) {
+ // No child element found, just remove asChild.
+ asChildAttr.remove()
+ continue
+ }
+
+ // Get child element info.
+ let childTagName: string
+ let childProps: string
+ let childChildren: string
+
+ if (childElement.getKind() === SyntaxKind.JsxSelfClosingElement) {
+ const selfClosing = childElement.asKindOrThrow(
+ SyntaxKind.JsxSelfClosingElement
+ )
+ childTagName = selfClosing.getTagNameNode().getText()
+ childProps = selfClosing
+ .getAttributes()
+ .map((attr) => attr.getText())
+ .join(" ")
+ childChildren = ""
+ } else {
+ const jsxChild = childElement.asKindOrThrow(SyntaxKind.JsxElement)
+ const openingEl = jsxChild.getOpeningElement()
+ childTagName = openingEl.getTagNameNode().getText()
+ childProps = openingEl
+ .getAttributes()
+ .map((attr) => attr.getText())
+ .join(" ")
+ // Get the children's text content.
+ childChildren = jsxChild
+ .getJsxChildren()
+ .map((c) => c.getText())
+ .join("")
+ }
+
+ // Determine if we need nativeButton={false}.
+ // Only add it on Button when the child is a non-button element.
+ const needsNativeButton =
+ parentTagName === "Button" &&
+ ELEMENTS_REQUIRING_NATIVE_BUTTON_FALSE.includes(childTagName)
+
+ transformations.push({
+ parentElement: jsxElement,
+ parentTagName,
+ childTagName,
+ childProps,
+ childChildren,
+ needsNativeButton,
})
- .map((attr) => attr.getText())
- .join(" ")
-
- // Build the render prop value.
- const renderValue = info.childProps
- ? `{<${info.childTagName} ${info.childProps} />}`
- : `{<${info.childTagName} />}`
-
- // Build new attributes.
- let newAttrs = existingAttrs ? `${existingAttrs} ` : ""
- newAttrs += `render=${renderValue}`
- if (info.needsNativeButton) {
- newAttrs += ` nativeButton={false}`
}
- // Build the new element text.
- const newChildren = info.childChildren.trim() ? `${info.childChildren}` : ""
+ // Apply transformations in reverse order to preserve node validity.
+ for (const info of transformations.reverse()) {
+ const openingElement = info.parentElement.getOpeningElement()
- const newElementText = `<${info.parentTagName} ${newAttrs}>${newChildren}${info.parentTagName}>`
+ // Get existing attributes (excluding asChild).
+ const existingAttrs = openingElement
+ .getAttributes()
+ .filter((attr) => {
+ if (attr.getKind() === SyntaxKind.JsxAttribute) {
+ const jsxAttr = attr.asKindOrThrow(SyntaxKind.JsxAttribute)
+ return jsxAttr.getNameNode().getText() !== "asChild"
+ }
+ return true
+ })
+ .map((attr) => attr.getText())
+ .join(" ")
- info.parentElement.replaceWithText(newElementText)
+ // Build the render prop value.
+ const renderValue = info.childProps
+ ? `{<${info.childTagName} ${info.childProps} />}`
+ : `{<${info.childTagName} />}`
+
+ // Build new attributes.
+ let newAttrs = existingAttrs ? `${existingAttrs} ` : ""
+ newAttrs += `render=${renderValue}`
+ if (info.needsNativeButton) {
+ newAttrs += ` nativeButton={false}`
+ }
+
+ // Build the new element text.
+ const newChildren = info.childChildren.trim()
+ ? `${info.childChildren}`
+ : ""
+
+ const newElementText = `<${info.parentTagName} ${newAttrs}>${newChildren}${info.parentTagName}>`
+
+ info.parentElement.replaceWithText(newElementText)
+ }
}
return sourceFile
diff --git a/packages/shadcn/src/utils/updaters/update-css-vars.ts b/packages/shadcn/src/utils/updaters/update-css-vars.ts
index f14af41638..fc1aea4109 100644
--- a/packages/shadcn/src/utils/updaters/update-css-vars.ts
+++ b/packages/shadcn/src/utils/updaters/update-css-vars.ts
@@ -5,7 +5,6 @@ import {
registryItemTailwindSchema,
} from "@/src/schema"
import { Config } from "@/src/utils/get-config"
-import { getPackageInfo } from "@/src/utils/get-package-info"
import { TailwindVersion } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { spinner } from "@/src/utils/spinner"
@@ -21,7 +20,6 @@ export async function updateCssVars(
options: {
cleanupDefaultNextStyles?: boolean
overwriteCssVars?: boolean
- initIndex?: boolean
silent?: boolean
tailwindVersion?: TailwindVersion
tailwindConfig?: z.infer["config"]
@@ -36,7 +34,6 @@ export async function updateCssVars(
silent: false,
tailwindVersion: "v3",
overwriteCssVars: false,
- initIndex: true,
...options,
}
const cssFilepath = config.resolvedPaths.tailwindCss
@@ -56,7 +53,6 @@ export async function updateCssVars(
tailwindVersion: options.tailwindVersion,
tailwindConfig: options.tailwindConfig,
overwriteCssVars: options.overwriteCssVars,
- initIndex: options.initIndex,
})
await fs.writeFile(cssFilepath, output, "utf8")
cssVarsSpinner.succeed()
@@ -71,13 +67,11 @@ export async function transformCssVars(
tailwindVersion?: TailwindVersion
tailwindConfig?: z.infer["config"]
overwriteCssVars?: boolean
- initIndex?: boolean
} = {
cleanupDefaultNextStyles: false,
tailwindVersion: "v3",
tailwindConfig: undefined,
overwriteCssVars: false,
- initIndex: false,
}
) {
options = {
@@ -85,7 +79,6 @@ export async function transformCssVars(
tailwindVersion: "v3",
tailwindConfig: undefined,
overwriteCssVars: false,
- initIndex: false,
...options,
}
@@ -98,18 +91,6 @@ export async function transformCssVars(
if (options.tailwindVersion === "v4") {
plugins = []
- // Only add tw-animate-css if project does not have tailwindcss-animate
- if (config.resolvedPaths?.cwd) {
- const packageInfo = getPackageInfo(config.resolvedPaths.cwd)
- if (
- !packageInfo?.dependencies?.["tailwindcss-animate"] &&
- !packageInfo?.devDependencies?.["tailwindcss-animate"] &&
- options.initIndex
- ) {
- plugins.push(addCustomImport({ params: "tw-animate-css" }))
- }
- }
-
plugins.push(addCustomVariant({ params: "dark (&:is(.dark *))" }))
if (options.cleanupDefaultNextStyles) {
@@ -130,12 +111,6 @@ export async function transformCssVars(
}
}
- if (config.tailwind.cssVariables && options.initIndex) {
- plugins.push(
- updateBaseLayerPlugin({ tailwindVersion: options.tailwindVersion })
- )
- }
-
const result = await postcss(plugins).process(input, {
from: undefined,
})
@@ -151,81 +126,6 @@ export async function transformCssVars(
return output
}
-function updateBaseLayerPlugin({
- tailwindVersion,
-}: {
- tailwindVersion?: TailwindVersion
-}) {
- return {
- postcssPlugin: "update-base-layer",
- Once(root: Root) {
- const requiredRules = [
- {
- selector: "*",
- apply:
- tailwindVersion === "v4"
- ? "border-border outline-ring/50"
- : "border-border",
- },
- { selector: "body", apply: "bg-background text-foreground" },
- ]
-
- let baseLayer = root.nodes.find(
- (node): node is AtRule =>
- node.type === "atrule" &&
- node.name === "layer" &&
- node.params === "base" &&
- requiredRules.every(({ selector, apply }) =>
- node.nodes?.some(
- (rule): rule is Rule =>
- rule.type === "rule" &&
- rule.selector === selector &&
- rule.nodes.some(
- (applyRule): applyRule is AtRule =>
- applyRule.type === "atrule" &&
- applyRule.name === "apply" &&
- applyRule.params === apply
- )
- )
- )
- ) as AtRule | undefined
-
- if (!baseLayer) {
- baseLayer = postcss.atRule({
- name: "layer",
- params: "base",
- raws: { semicolon: true, between: " ", before: "\n" },
- })
- root.append(baseLayer)
- root.insertBefore(baseLayer, postcss.comment({ text: "---break---" }))
- }
-
- requiredRules.forEach(({ selector, apply }) => {
- const existingRule = baseLayer?.nodes?.find(
- (node): node is Rule =>
- node.type === "rule" && node.selector === selector
- )
-
- if (!existingRule) {
- baseLayer?.append(
- postcss.rule({
- selector,
- nodes: [
- postcss.atRule({
- name: "apply",
- params: apply,
- raws: { semicolon: true, before: "\n " },
- }),
- ],
- raws: { semicolon: true, between: " ", before: "\n " },
- })
- )
- }
- })
- },
- }
-}
-
function updateCssVarsPlugin(
cssVars: z.infer
) {
@@ -662,13 +562,13 @@ function addCustomImport({ params }: { params: string }) {
node.type === "atrule" && node.name === "import"
)
- // Find custom variant node (to ensure we insert before it)
+ // Find custom variant node (to ensure we insert before it).
const customVariantNode = root.nodes.find(
(node): node is AtRule =>
node.type === "atrule" && node.name === "custom-variant"
)
- // Check if our specific import already exists
+ // Check if our specific import already exists.
const hasImport = importNodes.some(
(node) => node.params.replace(/["']/g, "") === params
)
@@ -681,18 +581,18 @@ function addCustomImport({ params }: { params: string }) {
})
if (importNodes.length > 0) {
- // If there are existing imports, add after the last import
+ // If there are existing imports, add after the last import.
const lastImport = importNodes[importNodes.length - 1]
root.insertAfter(lastImport, importNode)
} else if (customVariantNode) {
- // If no imports but has custom-variant, insert before it
+ // If no imports but has custom-variant, insert before it.
root.insertBefore(customVariantNode, importNode)
root.insertBefore(
customVariantNode,
postcss.comment({ text: "---break---" })
)
} else {
- // If no imports and no custom-variant, insert at the start
+ // If no imports and no custom-variant, insert at the start.
root.prepend(importNode)
root.insertAfter(importNode, postcss.comment({ text: "---break---" }))
}
diff --git a/packages/shadcn/src/utils/updaters/update-css.ts b/packages/shadcn/src/utils/updaters/update-css.ts
index 13b2c74357..37243225f5 100644
--- a/packages/shadcn/src/utils/updaters/update-css.ts
+++ b/packages/shadcn/src/utils/updaters/update-css.ts
@@ -472,12 +472,23 @@ function processRule(parent: Root | AtRule, selector: string, properties: any) {
const atRuleMatch = prop.match(/@([a-zA-Z-]+)\s*(.*)/)
if (atRuleMatch) {
const [, atRuleName, atRuleParams] = atRuleMatch
- const atRule = postcss.atRule({
- name: atRuleName,
- params: atRuleParams,
- raws: { semicolon: true, before: "\n " },
- })
- rule.append(atRule)
+
+ // Check if this at-rule already exists in the rule.
+ const existingAtRule = rule.nodes?.find(
+ (node): node is AtRule =>
+ node.type === "atrule" &&
+ node.name === atRuleName &&
+ node.params === atRuleParams
+ )
+
+ if (!existingAtRule) {
+ const atRule = postcss.atRule({
+ name: atRuleName,
+ params: atRuleParams,
+ raws: { semicolon: true, before: "\n " },
+ })
+ rule.append(atRule)
+ }
}
} else if (typeof value === "string") {
const decl = postcss.decl({
diff --git a/packages/shadcn/test/utils/updaters/update-css.test.ts b/packages/shadcn/test/utils/updaters/update-css.test.ts
index c1d3a6fb5f..3470409922 100644
--- a/packages/shadcn/test/utils/updaters/update-css.test.ts
+++ b/packages/shadcn/test/utils/updaters/update-css.test.ts
@@ -853,6 +853,82 @@ describe("transformCss", () => {
`)
})
+ test("should add base layer styles from registry:style css field", async () => {
+ const input = `@import "tailwindcss";`
+
+ // This is the exact shape from the registry:style index item.
+ const result = await transformCss(input, {
+ '@import "tw-animate-css"': {},
+ '@import "shadcn/tailwind.css"': {},
+ "@layer base": {
+ "*": {
+ "@apply border-border outline-ring/50": {},
+ },
+ body: {
+ "@apply bg-background text-foreground": {},
+ },
+ },
+ })
+
+ expect(result).toMatchInlineSnapshot(`
+ "@import "tailwindcss";
+ @import "tw-animate-css";
+ @import "shadcn/tailwind.css";
+
+ @layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ }"
+ `)
+ })
+
+ test("should not duplicate base layer styles if already present", async () => {
+ const input = `@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}`
+
+ const result = await transformCss(input, {
+ '@import "tw-animate-css"': {},
+ '@import "shadcn/tailwind.css"': {},
+ "@layer base": {
+ "*": {
+ "@apply border-border outline-ring/50": {},
+ },
+ body: {
+ "@apply bg-background text-foreground": {},
+ },
+ },
+ })
+
+ expect(result).toMatchInlineSnapshot(`
+ "@import "tailwindcss";
+ @import "tw-animate-css";
+ @import "shadcn/tailwind.css";
+
+ @layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ }"
+ `)
+ })
+
test("should replace existing keyframes instead of duplicating", async () => {
const input = `@import "tailwindcss";