fix: handling of nested aschild transforms

This commit is contained in:
shadcn
2026-02-10 11:23:36 +04:00
parent ae95fbd1be
commit bbb59c9fe1
3 changed files with 297 additions and 101 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
fix handling of nested aschild transforms

View File

@@ -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 (
<DialogTrigger render={<a href="#" />} nativeButton={false}>Open Dialog</DialogTrigger>
<DialogTrigger render={<a href="#" />}>Open Dialog</DialogTrigger>
)
}"
`)
@@ -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 (
<Collapsible asChild>
<SidebarMenuButton asChild>
<a href="#">Home</a>
</SidebarMenuButton>
</Collapsible>
)
}`,
config: testConfig,
},
[transformAsChild]
)
).toMatchInlineSnapshot(`
"import * as React from "react"
export function Component() {
return (
<Collapsible render={<SidebarMenuButton render={<a href="#" />} />}>Home</Collapsible>
)
}"
`)
})
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 (
<DialogTrigger asChild>
<Button asChild>
<a href="#">Open</a>
</Button>
</DialogTrigger>
)
}`,
config: testConfig,
},
[transformAsChild]
)
).toMatchInlineSnapshot(`
"import * as React from "react"
export function Component() {
return (
<DialogTrigger render={<Button render={<a href="#" />} nativeButton={false} />}>Open</DialogTrigger>
)
}"
`)
})
test("transforms nested with sibling asChild elements", async () => {
expect(
await transform(
{
filename: "test.tsx",
raw: `import * as React from "react"
export function Component() {
return (
<div>
<Collapsible asChild>
<SidebarMenuButton asChild>
<a href="#">Home</a>
</SidebarMenuButton>
</Collapsible>
<DialogTrigger asChild>
<Button variant="outline">Edit</Button>
</DialogTrigger>
</div>
)
}`,
config: testConfig,
},
[transformAsChild]
)
).toMatchInlineSnapshot(`
"import * as React from "react"
export function Component() {
return (
<div>
<Collapsible render={<SidebarMenuButton render={<a href="#" />} />}>Home</Collapsible>
<DialogTrigger render={<Button variant="outline" />}>Edit</DialogTrigger>
</div>
)
}"
`)
})
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 (
<Collapsible asChild>
<SidebarMenuButton asChild>
<Icon className="size-4" />
</SidebarMenuButton>
</Collapsible>
)
}`,
config: testConfig,
},
[transformAsChild]
)
).toMatchInlineSnapshot(`
"import * as React from "react"
export function Component() {
return (
<Collapsible render={<SidebarMenuButton render={<Icon className="size-4" />} />}></Collapsible>
)
}"
`)
})
test("transforms triple-nested asChild", async () => {
expect(
await transform(
{
filename: "test.tsx",
raw: `import * as React from "react"
export function Component() {
return (
<TooltipTrigger asChild>
<Collapsible asChild>
<SidebarMenuButton asChild>
<a href="#">Home</a>
</SidebarMenuButton>
</Collapsible>
</TooltipTrigger>
)
}`,
config: testConfig,
},
[transformAsChild]
)
).toMatchInlineSnapshot(`
"import * as React from "react"
export function Component() {
return (
<TooltipTrigger render={<Collapsible render={<SidebarMenuButton render={<a href="#" />} />} />}>Home</TooltipTrigger>
)
}"
`)
})
})
describe("idempotency", () => {
test("running twice produces same output", async () => {
const input = `import * as React from "react"

View File

@@ -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