Skip to content

Instantly share code, notes, and snippets.

@phibr0
Created August 14, 2025 18:43
Show Gist options
  • Select an option

  • Save phibr0/48ac88eafbd711784963a3b72015fd09 to your computer and use it in GitHub Desktop.

Select an option

Save phibr0/48ac88eafbd711784963a3b72015fd09 to your computer and use it in GitHub Desktop.
converts radix-ui asChild to base-ui render={} prop
// converts radix-ui asChild to base-ui render={} prop
// pnpx jscodeshift -t deradix.ts . --extensions=tsx --parser=tsx
module.exports = function transform(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
let changed = false;
const isEmptyText = (node) => node.type === 'JSXText' && node.value.trim() === '';
root.find(j.JSXElement).forEach((path) => {
const opening = path.node.openingElement;
const attrs = opening.attributes || [];
const asIdx = attrs.findIndex(
(a) => a.type === 'JSXAttribute' && a.name && a.name.name === 'asChild',
);
if (asIdx === -1) {
return; // No asChild prop found
}
const asAttr = attrs[asIdx];
const isExplicitFalse =
asAttr.value &&
asAttr.value.type === 'JSXExpressionContainer' &&
asAttr.value.expression.type === 'BooleanLiteral' &&
asAttr.value.expression.value === false;
if (isExplicitFalse) {
return; // asChild={false}, skip
}
const nonWsChildren = (path.node.children || []).filter((c) => !isEmptyText(c));
// The asChild prop typically expects a single direct child or a single
// expression container wrapping a renderable element/expression.
if (nonWsChildren.length !== 1) {
return; // Cannot reliably migrate if multiple or no children
}
const soleChild = nonWsChildren[0];
let renderPropValue = null; // This will hold the AST node for render={...}
let newParentChildren = []; // Children that will remain inside the parent component
if (soleChild.type === 'JSXElement') {
// Case: <Parent asChild><Child>...</Child></Parent>
// The child's content becomes the new parent's content.
newParentChildren = soleChild.children || [];
// The render prop receives a self-closing version of the child.
renderPropValue = j.jsxElement(
j.jsxOpeningElement(
soleChild.openingElement.name,
soleChild.openingElement.attributes || [],
true, // Self-closing
),
null, // No closing element for self-closing
[], // No children for self-closing
);
// Preserve TS type parameters if present
if (soleChild.openingElement.typeParameters) {
renderPropValue.openingElement.typeParameters = soleChild.openingElement.typeParameters;
}
} else if (soleChild.type === 'JSXExpressionContainer') {
// Case: <Parent asChild>{expression}</Parent>
const expression = soleChild.expression;
if (expression.type === 'JSXElement') {
// Case: <Parent asChild>{<Child>...</Child>}</Parent>
// Similar to the direct JSXElement case, but the child is wrapped.
newParentChildren = expression.children || [];
renderPropValue = j.jsxElement(
j.jsxOpeningElement(
expression.openingElement.name,
expression.openingElement.attributes || [],
true, // Self-closing
),
null,
[],
);
if (expression.openingElement.typeParameters) {
renderPropValue.openingElement.typeParameters = expression.openingElement.typeParameters;
}
} else if (
expression.type === 'ConditionalExpression' ||
expression.type === 'LogicalExpression'
) {
// Case: <Parent asChild>{cond ? <Child1/> : <Child2/>}</Parent>
// Case: <Parent asChild>{cond && <Child/>}</Parent>
// The entire expression becomes the value of the render prop.
renderPropValue = expression;
// Conditional/Logical expressions don't have children to hoist.
newParentChildren = [];
} else {
// Other types of expressions (e.g., {variable}, {1+2}) are not typical
// for asChild and cannot be directly converted to render={JSXElement}.
return;
}
} else {
// If the sole child is not a JSXElement or JSXExpressionContainer
// (e.g., plain JSXText like `<div>Text</div>`), it's not a valid
// asChild usage to convert to render={JSXElement}.
return;
}
if (!renderPropValue) {
// Should not be reached if checks are robust, but a safeguard.
return;
}
// Build the new render attribute
const renderAttr = j.jsxAttribute(
j.jsxIdentifier('render'),
j.jsxExpressionContainer(renderPropValue),
);
// Remove the original asChild attribute
opening.attributes.splice(asIdx, 1);
// Add or replace the render attribute
const existingRenderIdx = (opening.attributes || []).findIndex(
(a) => a.type === 'JSXAttribute' && a.name && a.name.name === 'render',
);
if (existingRenderIdx === -1) {
opening.attributes.push(renderAttr);
} else {
// Replace existing render prop if it somehow exists (unlikely but safe)
opening.attributes[existingRenderIdx] = renderAttr;
}
// Update the parent's children with the hoisted content
path.node.children = newParentChildren;
changed = true;
});
return changed ? root.toSource() : null;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment