Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/babel-plugin/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) });
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is would be the full example of what we need this for—do you think it would work?

const styles = css({ fontSize: 12 });
const MyDiv = ({ children, className }) => {
  return cloneElement(children, { css: cx(styles, props.className) });
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other question I've got, probably for Compiled, is should this be props.css or props.className exclusively?

Copy link
Copy Markdown
Collaborator Author

@danieldelcore danieldelcore Oct 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same question.

I'll add a test case for the example you provided! It should just work because that part of the algorithm gets handed back to compiled

};
`);

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 (
<CC>
<CS>{[_]}</CS>
{cloneElement(children, {
className: ax(["_1wyb1fwx"]),
})}
</CC>
);
};
"
`);
});

// 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(`
Expand Down
14 changes: 14 additions & 0 deletions packages/babel-plugin/src/babel-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -295,6 +296,19 @@ export default declare<State>((api) => {
path: NodePath<t.TaggedTemplateExpression> | NodePath<t.CallExpression>,
state: State
) {
if (
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this check that cloneElement is imported from react too?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes definitely and also should check for aliased imports

import { cloneElement as foo } from 'react';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit: I added support for alias and member expressions in the latest commit

t.isCallExpression(path.node) &&
t.isIdentifier(path.node.callee) &&
path.node.callee.name === 'cloneElement'
) {
visitCloneElementPath(path as NodePath<t.CallExpression>, {
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?
Expand Down
183 changes: 183 additions & 0 deletions packages/babel-plugin/src/clone-element/index.ts
Original file line number Diff line number Diff line change
@@ -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<t.Expression>) => {
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[] | 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(<Component />, { 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<t.CallExpression>, 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<t.CallExpression>;

if (!ancestorPath) {
return;
}

ancestorPath.replaceWith(buildCompiledCloneElement(ancestorPath.node, builtCss, meta));
},
});

// // Second pass to replace all usages of `style`.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO, makes sense to hook the style prop up as well

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does anything else ever use style in Babel? style={css(…)} isn't valid, right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the pass over the style element is intended for outputting CSS vars 🤔 or something of that nature.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's only when it's in runtime mode, but yeah, maybe right…really glad if you can learn this part of it because I have no clue how extraction or runtime actually work 🤣

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither hahah

// 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);
// }
// }
// },
// });
};
55 changes: 55 additions & 0 deletions packages/babel-plugin/src/utils/build-compiled-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone is using a variable/spread/etc it'll probably be impossible to reliably fish out and modify the CSS prop.
Are there any standard approaches to this problem in use in the repo already? Do we throw/Log an err/try really hard to locate the variable?


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