diff --git a/packages/babel-plugin/src/__tests__/index.test.ts b/packages/babel-plugin/src/__tests__/index.test.ts
index 786122ae7..63082ac3e 100644
--- a/packages/babel-plugin/src/__tests__/index.test.ts
+++ b/packages/babel-plugin/src/__tests__/index.test.ts
@@ -114,6 +114,95 @@ describe('babel plugin', () => {
`);
});
+ it('should transform cloneElement css prop', () => {
+ const actual = transform(`
+ import { cloneElement } from 'react';
+ import { css } from '@compiled/react';
+
+ const MyDiv = ({ children }) => {
+ return cloneElement(children, { css: css({ fontSize: 12 }) });
+ };
+ `);
+
+ expect(actual).toMatchInlineSnapshot(`
+ "/* File generated by @compiled/babel-plugin v0.0.0 */
+ import * as React from "react";
+ import { ax, ix, CC, CS } from "@compiled/react/runtime";
+ import { cloneElement } from "react";
+ const _ = "._1wyb1fwx{font-size:12px}";
+ const MyDiv = ({ children }) => {
+ return (
+
+ {[_]}
+ {cloneElement(children, {
+ className: ax(["_1wyb1fwx"]),
+ })}
+
+ );
+ };
+ "
+ `);
+ });
+
+ it('should transform cloneElement css prop with aliased import', () => {
+ const actual = transform(`
+ import { cloneElement as CE } from 'react';
+ import { css } from '@compiled/react';
+
+ const MyDiv = ({ children }) => {
+ return CE(children, { css: css({ fontSize: 12 }) });
+ };
+ `);
+
+ expect(actual).toMatchInlineSnapshot(`
+ "/* File generated by @compiled/babel-plugin v0.0.0 */
+ import * as React from "react";
+ import { ax, ix, CC, CS } from "@compiled/react/runtime";
+ import { cloneElement as CE } from "react";
+ const _ = "._1wyb1fwx{font-size:12px}";
+ const MyDiv = ({ children }) => {
+ return (
+
+ {[_]}
+ {CE(children, {
+ className: ax(["_1wyb1fwx"]),
+ })}
+
+ );
+ };
+ "
+ `);
+ });
+
+ it('should transform React.cloneElement css prop', () => {
+ const actual = transform(`
+ import React from 'react';
+ import { css } from '@compiled/react';
+
+ const MyDiv = ({ children }) => {
+ return React.cloneElement(children, { css: css({ fontSize: 12 }) });
+ };
+ `);
+
+ expect(actual).toMatchInlineSnapshot(`
+ "/* File generated by @compiled/babel-plugin v0.0.0 */
+ import { ax, ix, CC, CS } from "@compiled/react/runtime";
+ import React from "react";
+ const _ = "._1wyb1fwx{font-size:12px}";
+ const MyDiv = ({ children }) => {
+ return (
+
+ {[_]}
+ {React.cloneElement(children, {
+ className: ax(["_1wyb1fwx"]),
+ })}
+
+ );
+ };
+ "
+ `);
+ });
+
// TODO Removing import React from 'react' breaks this test
it('should preserve comments at the top of the processed file before inserting runtime imports', () => {
const actual = transform(`
diff --git a/packages/babel-plugin/src/babel-plugin.ts b/packages/babel-plugin/src/babel-plugin.ts
index 380101199..5c85995fb 100644
--- a/packages/babel-plugin/src/babel-plugin.ts
+++ b/packages/babel-plugin/src/babel-plugin.ts
@@ -14,6 +14,7 @@ import {
} from '@compiled/utils';
import { visitClassNamesPath } from './class-names';
+import { visitCloneElementPath } from './clone-element';
import { visitCssMapPath } from './css-map';
import { visitCssPropPath } from './css-prop';
import { visitStyledPath } from './styled';
@@ -65,6 +66,26 @@ const findClassicJsxPragmaImport: Visitor = {
},
};
+const findReactImportSpecifier: Visitor = {
+ ImportSpecifier(path, state) {
+ const specifier = path.node;
+
+ t.assertImportDeclaration(path.parent);
+ if (path.parent.source.value !== 'react') {
+ return;
+ }
+
+ if (
+ (specifier.imported.type === 'StringLiteral' &&
+ specifier.imported.value === 'cloneElement') ||
+ (specifier.imported.type === 'Identifier' && specifier.imported.name === 'cloneElement')
+ ) {
+ state.reactImports = state.reactImports || {};
+ state.reactImports.cloneElement = specifier.local.name;
+ }
+ },
+};
+
export default declare((api) => {
api.assertVersion(7);
@@ -124,6 +145,7 @@ export default declare((api) => {
// Handle classic JSX pragma, if it exists
path.traverse(findClassicJsxPragmaImport, this);
+ path.traverse(findReactImportSpecifier, this);
if (!file.ast.comments) {
return;
@@ -295,6 +317,26 @@ export default declare((api) => {
path: NodePath | NodePath,
state: State
) {
+ if (
+ (t.isCallExpression(path.node) &&
+ t.isIdentifier(path.node.callee) &&
+ path.node.callee.name === state.reactImports?.cloneElement) ||
+ // handle member expression React.cloneElement
+ (t.isCallExpression(path.node) &&
+ t.isMemberExpression(path.node.callee) &&
+ t.isIdentifier(path.node.callee.object) &&
+ path.node.callee.object.name === 'React' &&
+ t.isIdentifier(path.node.callee.property) &&
+ path.node.callee.property.name === 'cloneElement')
+ ) {
+ visitCloneElementPath(path as NodePath, {
+ context: 'root',
+ state,
+ parentPath: path,
+ });
+ return;
+ }
+
if (isTransformedJsxFunction(path, state)) {
throw buildCodeFrameError(
`Found a \`jsx\` function call in the Babel output where one should not have been generated. Was Compiled not set up correctly?
diff --git a/packages/babel-plugin/src/clone-element/index.ts b/packages/babel-plugin/src/clone-element/index.ts
new file mode 100644
index 000000000..3cb6deffe
--- /dev/null
+++ b/packages/babel-plugin/src/clone-element/index.ts
@@ -0,0 +1,131 @@
+import type { NodePath } from '@babel/core';
+import * as t from '@babel/types';
+
+import type { Metadata } from '../types';
+import { buildCompiledCloneElement } from '../utils/build-compiled-component';
+import { buildCss } from '../utils/css-builders';
+import { getRuntimeClassNameLibrary } from '../utils/get-runtime-class-name-library';
+import { resolveIdentifierComingFromDestructuring } from '../utils/resolve-binding';
+import { transformCssItems } from '../utils/transform-css-items';
+import type { CSSOutput } from '../utils/types';
+
+/**
+ * Extracts styles from an expression.
+ *
+ * @param path Expression node
+ */
+const extractStyles = (path: NodePath): t.Expression[] | t.Expression | undefined => {
+ if (
+ t.isCallExpression(path.node) &&
+ t.isIdentifier(path.node.callee) &&
+ path.node.callee.name === 'css' &&
+ t.isExpression(path.node.arguments[0])
+ ) {
+ // css({}) call
+ const styles = path.node.arguments as t.Expression[];
+ return styles;
+ }
+
+ if (
+ t.isCallExpression(path.node) &&
+ t.isIdentifier(path.node.callee) &&
+ t.isExpression(path.node.arguments[0]) &&
+ path.scope.hasOwnBinding(path.node.callee.name)
+ ) {
+ const binding = path.scope.getBinding(path.node.callee.name)?.path.node;
+
+ if (
+ !!resolveIdentifierComingFromDestructuring({ name: 'css', node: binding as t.Expression })
+ ) {
+ // c({}) rename call
+ const styles = path.node.arguments as t.Expression[];
+ return styles;
+ }
+ }
+
+ if (t.isCallExpression(path.node) && t.isMemberExpression(path.node.callee)) {
+ if (
+ t.isIdentifier(path.node.callee.property) &&
+ path.node.callee.property.name === 'css' &&
+ t.isExpression(path.node.arguments[0])
+ ) {
+ // props.css({}) call
+ const styles = path.node.arguments as t.Expression[];
+ return styles;
+ }
+ }
+
+ if (t.isTaggedTemplateExpression(path.node)) {
+ const styles = path.node.quasi;
+ return styles;
+ }
+
+ return undefined;
+};
+
+/**
+ * Takes a React.cloneElement invocation and transforms it into a compiled component.
+ * This method will traverse the AST twice,
+ * once to replace all calls to `css`,
+ * and another to replace `style` usage.
+ *
+ * `React.cloneElement(, { css: {} })`
+ *
+ * @param path {NodePath} The opening JSX element
+ * @param meta {Metadata} Useful metadata that can be used during the transformation
+ */
+export const visitCloneElementPath = (path: NodePath, meta: Metadata): void => {
+ // if props contains a `css` prop, we need to transform it.
+ const props = path.node.arguments[1];
+
+ if (props.type !== 'ObjectExpression') {
+ // TODO: handle this case properly
+ console.error('cloneElement props are not an ObjectExpression');
+ return;
+ }
+
+ const collectedVariables: CSSOutput['variables'] = [];
+ const collectedSheets: string[] = [];
+
+ // First pass to replace all usages of `css({})`
+ path.traverse({
+ CallExpression(path) {
+ const styles = extractStyles(path);
+
+ if (!styles) {
+ // Nothing to do - skip.
+ return;
+ }
+
+ const builtCss = buildCss(styles, meta);
+ const { sheets, classNames } = transformCssItems(builtCss.css, meta);
+
+ collectedVariables.push(...builtCss.variables);
+ collectedSheets.push(...sheets);
+
+ path.replaceWith(
+ t.callExpression(t.identifier(getRuntimeClassNameLibrary(meta)), [
+ t.arrayExpression(classNames),
+ ])
+ );
+
+ // find ancestor cloneElement callExpression
+ const ancestorPath = path.findParent(
+ (p) =>
+ (p.isCallExpression() &&
+ t.isIdentifier(p.node.callee) &&
+ p.node.callee.name === meta.state.reactImports?.cloneElement) ||
+ (p.isCallExpression() &&
+ t.isMemberExpression(p.node.callee) &&
+ t.isIdentifier(p.node.callee.property) &&
+ p.node.callee.property.name === 'cloneElement')
+ ) as NodePath;
+
+ if (!ancestorPath) {
+ return;
+ }
+
+ ancestorPath.replaceWith(buildCompiledCloneElement(ancestorPath.node, builtCss, meta));
+ },
+ });
+};
diff --git a/packages/babel-plugin/src/types.ts b/packages/babel-plugin/src/types.ts
index f697dda57..679da5614 100644
--- a/packages/babel-plugin/src/types.ts
+++ b/packages/babel-plugin/src/types.ts
@@ -138,6 +138,22 @@ export interface State extends PluginPass {
cssMap?: string[];
};
+ /**
+ * Returns the name of the cloneElement import specifier if it is imported.
+ * If an alias is used, the alias will be returned.
+ *
+ * E.g:
+ *
+ * ```
+ * import { cloneElement as myCloneElement } from 'react';
+ * ```
+ *
+ * Returns `myCloneElement`.
+ */
+ reactImports?: {
+ cloneElement?: string;
+ };
+
usesXcss?: boolean;
importedCompiledImports?: {
diff --git a/packages/babel-plugin/src/utils/build-compiled-component.ts b/packages/babel-plugin/src/utils/build-compiled-component.ts
index 82ad36a0f..067f8e37c 100644
--- a/packages/babel-plugin/src/utils/build-compiled-component.ts
+++ b/packages/babel-plugin/src/utils/build-compiled-component.ts
@@ -142,3 +142,61 @@ export const buildCompiledComponent = (
return compiledTemplate(node, sheets, meta);
};
+
+/**
+ * Accepts a cloneElement node and returns a Compiled Component AST.
+ *
+ * @param node Originating cloneElement node
+ * @param cssOutput CSS and variables to place onto the component
+ * @param meta {Metadata} Useful metadata that can be used during the transformation
+ */
+export const buildCompiledCloneElement = (
+ node: t.CallExpression,
+ cssOutput: CSSOutput,
+ meta: Metadata
+): t.Node => {
+ const { sheets, classNames } = transformCssItems(cssOutput.css, meta);
+
+ const props = node.arguments[1];
+
+ // TODO: This is a temporary fix to prevent the plugin from crashing when the second argument of cloneElement is not an object expression.
+ if (!t.isObjectExpression(props)) {
+ throw new Error('Second argument of cloneElement must be an object expression.');
+ }
+
+ const [classNameProperty] = props.properties.filter(
+ (prop) => t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'className'
+ );
+
+ if (
+ classNameProperty &&
+ t.isObjectProperty(classNameProperty) &&
+ t.isIdentifier(classNameProperty.value)
+ ) {
+ const values: t.Expression[] = classNames.concat(classNameProperty.value);
+
+ classNameProperty.value = t.callExpression(t.identifier(getRuntimeClassNameLibrary(meta)), [
+ t.arrayExpression(values),
+ ]);
+ } else {
+ props.properties.push(
+ t.objectProperty(
+ t.identifier('className'),
+ t.callExpression(t.identifier(getRuntimeClassNameLibrary(meta)), [
+ t.arrayExpression(classNames),
+ ])
+ )
+ );
+ }
+
+ // remove css prop from props object
+ const cssPropIndex = props.properties.findIndex(
+ (prop) => t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'css'
+ );
+
+ if (cssPropIndex !== -1) {
+ props.properties.splice(cssPropIndex, 1);
+ }
+
+ return compiledTemplate(node, sheets, meta);
+};