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); +};