From 86aa21061bf4ae830d41c7082490b30c43974b85 Mon Sep 17 00:00:00 2001 From: Daniel Del Core Date: Tue, 22 Oct 2024 15:23:57 +1100 Subject: [PATCH 1/2] cloneElement support --- .../babel-plugin/src/__tests__/index.test.ts | 30 +++ packages/babel-plugin/src/babel-plugin.ts | 14 ++ .../babel-plugin/src/clone-element/index.ts | 183 ++++++++++++++++++ .../src/utils/build-compiled-component.ts | 55 ++++++ 4 files changed, 282 insertions(+) create mode 100644 packages/babel-plugin/src/clone-element/index.ts diff --git a/packages/babel-plugin/src/__tests__/index.test.ts b/packages/babel-plugin/src/__tests__/index.test.ts index 786122ae7..c3f7c95a2 100644 --- a/packages/babel-plugin/src/__tests__/index.test.ts +++ b/packages/babel-plugin/src/__tests__/index.test.ts @@ -114,6 +114,36 @@ describe('babel plugin', () => { `); }); + it('should transform React.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"]), + })} + + ); + }; + " + `); + }); + // 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..31d320e63 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'; @@ -295,6 +296,19 @@ export default declare((api) => { path: NodePath | NodePath, state: State ) { + if ( + t.isCallExpression(path.node) && + t.isIdentifier(path.node.callee) && + path.node.callee.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..8261fec9f --- /dev/null +++ b/packages/babel-plugin/src/clone-element/index.ts @@ -0,0 +1,183 @@ +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 { buildCssVariables } from '../utils/build-css-variables'; +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'; + +/** + * Handles style prop value. If variables are present it will replace its value with it + * otherwise will add undefined. + * + * @param variables CSS variables prop to be placed as inline styles + * @param path Any Expression path + */ +const handleStyleProp = (variables: CSSOutput['variables'], path: NodePath) => { + const styleValue = variables.length + ? t.objectExpression(buildCssVariables(variables)) + : t.identifier('undefined'); + + path.replaceWith(styleValue); +}; + +/** + * 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 === 'cloneElement' + ) as NodePath; + + if (!ancestorPath) { + return; + } + + ancestorPath.replaceWith(buildCompiledCloneElement(ancestorPath.node, builtCss, meta)); + }, + }); + + // // Second pass to replace all usages of `style`. + // path.traverse({ + // Expression(path) { + // if (t.isIdentifier(path.node)) { + // if (path.parentPath.isProperty()) { + // return; + // } + + // // style={style} + // if (path.node.name === 'style' && path.scope.hasOwnBinding('style')) { + // handleStyleProp(collectedVariables, path); + // } + + // // style={style} rename prop + // if (path.scope.hasOwnBinding(path.node.name)) { + // const binding = path.scope.getBinding(path.node.name)?.path.node; + + // if ( + // !!resolveIdentifierComingFromDestructuring({ + // name: 'style', + // node: binding as t.Expression, + // }) + // ) { + // handleStyleProp(collectedVariables, path); + // } + // } + // } else if (t.isMemberExpression(path.node)) { + // // filter out invalid calls like dontexist.style + // if (t.isIdentifier(path.node.object) && !path.scope.hasOwnBinding(path.node.object.name)) { + // return; + // } + + // // style={props.style} + // if (t.isIdentifier(path.node.property) && path.node.property.name === 'style') { + // handleStyleProp(collectedVariables, path); + // } + // } + // }, + // }); +}; diff --git a/packages/babel-plugin/src/utils/build-compiled-component.ts b/packages/babel-plugin/src/utils/build-compiled-component.ts index 82ad36a0f..f4804a5df 100644 --- a/packages/babel-plugin/src/utils/build-compiled-component.ts +++ b/packages/babel-plugin/src/utils/build-compiled-component.ts @@ -142,3 +142,58 @@ 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)) { + const classNameExpression = getExpression(classNameProperty.value); + const values: t.Expression[] = classNames.concat(classNameExpression); + + 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); +}; From b6b97361a8da62326ebb7caa6924641e6a9453f5 Mon Sep 17 00:00:00 2001 From: Daniel Del Core Date: Thu, 24 Oct 2024 16:32:36 +1100 Subject: [PATCH 2/2] add import alias/member expression support --- .../babel-plugin/src/__tests__/index.test.ts | 61 ++++++++++++++++- packages/babel-plugin/src/babel-plugin.ts | 34 +++++++++- .../babel-plugin/src/clone-element/index.ts | 66 ++----------------- packages/babel-plugin/src/types.ts | 16 +++++ .../src/utils/build-compiled-component.ts | 9 ++- 5 files changed, 120 insertions(+), 66 deletions(-) diff --git a/packages/babel-plugin/src/__tests__/index.test.ts b/packages/babel-plugin/src/__tests__/index.test.ts index c3f7c95a2..63082ac3e 100644 --- a/packages/babel-plugin/src/__tests__/index.test.ts +++ b/packages/babel-plugin/src/__tests__/index.test.ts @@ -114,7 +114,7 @@ describe('babel plugin', () => { `); }); - it('should transform React.cloneElement css prop', () => { + it('should transform cloneElement css prop', () => { const actual = transform(` import { cloneElement } from 'react'; import { css } from '@compiled/react'; @@ -144,6 +144,65 @@ describe('babel plugin', () => { `); }); + 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 31d320e63..5c85995fb 100644 --- a/packages/babel-plugin/src/babel-plugin.ts +++ b/packages/babel-plugin/src/babel-plugin.ts @@ -66,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); @@ -125,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; @@ -297,9 +318,16 @@ export default declare((api) => { state: State ) { if ( - t.isCallExpression(path.node) && - t.isIdentifier(path.node.callee) && - path.node.callee.name === 'cloneElement' + (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', diff --git a/packages/babel-plugin/src/clone-element/index.ts b/packages/babel-plugin/src/clone-element/index.ts index 8261fec9f..3cb6deffe 100644 --- a/packages/babel-plugin/src/clone-element/index.ts +++ b/packages/babel-plugin/src/clone-element/index.ts @@ -3,28 +3,12 @@ import * as t from '@babel/types'; import type { Metadata } from '../types'; import { buildCompiledCloneElement } from '../utils/build-compiled-component'; -import { buildCssVariables } from '../utils/build-css-variables'; 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'; -/** - * Handles style prop value. If variables are present it will replace its value with it - * otherwise will add undefined. - * - * @param variables CSS variables prop to be placed as inline styles - * @param path Any Expression path - */ -const handleStyleProp = (variables: CSSOutput['variables'], path: NodePath) => { - const styleValue = variables.length - ? t.objectExpression(buildCssVariables(variables)) - : t.identifier('undefined'); - - path.replaceWith(styleValue); -}; - /** * Extracts styles from an expression. * @@ -128,9 +112,13 @@ export const visitCloneElementPath = (path: NodePath, meta: Me // find ancestor cloneElement callExpression const ancestorPath = path.findParent( (p) => - p.isCallExpression() && - t.isIdentifier(p.node.callee) && - p.node.callee.name === 'cloneElement' + (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) { @@ -140,44 +128,4 @@ export const visitCloneElementPath = (path: NodePath, meta: Me ancestorPath.replaceWith(buildCompiledCloneElement(ancestorPath.node, builtCss, meta)); }, }); - - // // Second pass to replace all usages of `style`. - // path.traverse({ - // Expression(path) { - // if (t.isIdentifier(path.node)) { - // if (path.parentPath.isProperty()) { - // return; - // } - - // // style={style} - // if (path.node.name === 'style' && path.scope.hasOwnBinding('style')) { - // handleStyleProp(collectedVariables, path); - // } - - // // style={style} rename prop - // if (path.scope.hasOwnBinding(path.node.name)) { - // const binding = path.scope.getBinding(path.node.name)?.path.node; - - // if ( - // !!resolveIdentifierComingFromDestructuring({ - // name: 'style', - // node: binding as t.Expression, - // }) - // ) { - // handleStyleProp(collectedVariables, path); - // } - // } - // } else if (t.isMemberExpression(path.node)) { - // // filter out invalid calls like dontexist.style - // if (t.isIdentifier(path.node.object) && !path.scope.hasOwnBinding(path.node.object.name)) { - // return; - // } - - // // style={props.style} - // if (t.isIdentifier(path.node.property) && path.node.property.name === 'style') { - // handleStyleProp(collectedVariables, path); - // } - // } - // }, - // }); }; 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 f4804a5df..067f8e37c 100644 --- a/packages/babel-plugin/src/utils/build-compiled-component.ts +++ b/packages/babel-plugin/src/utils/build-compiled-component.ts @@ -168,9 +168,12 @@ export const buildCompiledCloneElement = ( (prop) => t.isObjectProperty(prop) && t.isIdentifier(prop.key) && prop.key.name === 'className' ); - if (classNameProperty && t.isObjectProperty(classNameProperty)) { - const classNameExpression = getExpression(classNameProperty.value); - const values: t.Expression[] = classNames.concat(classNameExpression); + 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),