diff --git a/package.json b/package.json index 267d96a6..e0b3f6aa 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "html-webpack-plugin": "^4.2.1", "husky": "^4.2.5", "jest": "^25.4.0", + "jest-diff": "^25.5.0", "jest-file-snapshot": "^0.3.6", "lint-staged": "^10.2.0", "mini-css-extract-plugin": "^0.9.0", diff --git a/src/features/css-prop-inline.ts b/src/features/css-prop-inline.ts new file mode 100644 index 00000000..06199f67 --- /dev/null +++ b/src/features/css-prop-inline.ts @@ -0,0 +1,190 @@ +import { NodePath } from '@babel/core'; +import * as t from '@babel/types'; + +import { PluginState } from '../types'; +import truthy from '../utils/truthy'; + +type Var = [t.StringLiteral, t.Expression, t.StringLiteral | undefined]; + +const isSpread = (p: NodePath): p is NodePath => + p.isJSXSpreadAttribute(); + +const assign = (...nodes: t.Expression[]) => { + const objs = nodes.filter(truthy); + if (!objs.length) return null; + return objs.length === 1 + ? objs[0] + : t.callExpression( + t.memberExpression(t.identifier('Object'), t.identifier('assign')), + objs, + ); +}; + +const findPropIndex = >(attrs: T[], key: string) => + attrs.findIndex((a) => a.isJSXAttribute() && a.node.name.name === key); + +const unwrapValue = (attr: NodePath) => { + const value = attr.get('value'); + return value.isJSXExpressionContainer() + ? value.get('expression') + : (value as NodePath); +}; + +function classNames(nodes: t.Expression[]) { + const classes = nodes.filter(truthy); + if (!classes.length) return null; + return classes.length === 1 + ? classes[0] + : t.callExpression( + t.memberExpression( + t.templateLiteral( + Array.from({ length: classes.length + 1 }, (_, idx) => + t.templateElement({ + raw: idx === 0 || idx === classes.length ? '' : ' ', + }), + ), + classes, + ), + t.identifier('trim'), + ), + [], + ); +} + +const falsyToString = (value?: t.Expression, op: '||' | '??' = '||') => + value && t.logicalExpression(op, value, t.stringLiteral('')); + +const pluckPropertyFromSpreads = ( + attrs: NodePath[], + start: number, + property: string, + op: '||' | '??' = '||', +) => { + const spreads = (start === -1 ? attrs : attrs.slice(start)) + .filter(isSpread) + .map(({ node }) => + t.memberExpression(node.argument, t.identifier(property)), + ); + + return spreads.length + ? spreads + .slice(1) + .reduce( + (curr: any, next: any) => t.logicalExpression(op, next!, curr), + spreads[0], + ) + : null; +}; + +function buildStyleAttribute( + attrs: NodePath[], + vars: t.ArrayExpression, +) { + const idx = findPropIndex(attrs, 'style'); + const style = idx === -1 ? null : (attrs[idx] as NodePath); + + const props = vars.elements.map((el: t.ArrayExpression) => { + const [id, value, unit] = el.elements as Var; + return t.objectProperty( + t.stringLiteral(`--${id.value}`), + unit + ? t.binaryExpression( + '+', + t.callExpression(t.identifier('String'), [value]), + unit, + ) + : value, + ); + }); + const spreadsAfterStyle = pluckPropertyFromSpreads(attrs, idx, 'style'); + + if (!style) { + return assign(t.objectExpression(props), spreadsAfterStyle); + } + + const styleValue = unwrapValue(style); + const styleValueNode = styleValue.node as t.Expression; + let assignee = styleValueNode; + + style.remove(); + + if (spreadsAfterStyle) { + assignee = t.logicalExpression('||', spreadsAfterStyle, assignee); + } else if (styleValue.isObjectExpression()) { + // @ts-ignore + styleValue.pushContainer('properties', props); + return styleValueNode; + } + + return assign(t.objectExpression(props), assignee); +} + +function buildClassNameAttribute( + attrs: NodePath[], + rootId: t.Identifier, + variants: t.ArrayExpression, +) { + const idx = findPropIndex(attrs, 'className'); + const className = + idx === -1 ? null : (attrs[idx] as NodePath); + + const values: any[] = [ + t.memberExpression(rootId, t.identifier('cls1')), + ...variants.elements, + ]; + + if (className) { + const classNameValue = unwrapValue(className); + + className.remove(); + // take the explicit className attribute and coerce to a string if necessary + values.unshift( + classNameValue.isStringLiteral() + ? classNameValue.node + : falsyToString(classNameValue.node as t.Expression), + ); + } else { + values.unshift( + falsyToString( + pluckPropertyFromSpreads(attrs, idx, 'className', '??'), + '??', + ), + ); + } + + return classNames(values)!; +} + +export function inlineJsx( + path: NodePath, + node: t.ArrayExpression, + _state: PluginState, +) { + const parent = path.parentPath as NodePath; + const attrs = parent.get('attributes'); + + const [rootId, vars, variants] = node.elements as [ + t.Identifier, + t.ArrayExpression, + t.ArrayExpression, + ]; + + const style = buildStyleAttribute(attrs, vars); + const className = buildClassNameAttribute(attrs, rootId, variants); + + // @ts-ignore + parent.pushContainer( + 'attributes', + [ + style && + t.jsxAttribute( + t.jsxIdentifier('style'), + t.jsxExpressionContainer(style), + ), + t.jsxAttribute( + t.jsxIdentifier('className'), + t.jsxExpressionContainer(className), + ), + ].filter(truthy), + ); +} diff --git a/src/features/css-prop.ts b/src/features/css-prop.ts index 09609b2d..c92a659e 100644 --- a/src/features/css-prop.ts +++ b/src/features/css-prop.ts @@ -1,17 +1,19 @@ -import chalk from 'chalk'; import { NodePath } from '@babel/core'; import generate from '@babel/generator'; import * as t from '@babel/types'; +import chalk from 'chalk'; import { DynamicStyle, PluginState } from '../types'; +import { COMPONENTS, HAS_CSS_PROP, STYLES } from '../utils/Symbols'; import addPragma from '../utils/addPragma'; import buildTaggedTemplate from '../utils/buildTaggedTemplate'; import createStyleNode from '../utils/createStyleNode'; import getNameFromPath from '../utils/getNameFromPath'; +import isCreateElementCall from '../utils/isCreateElementCall'; import isCssTag from '../utils/isCssTag'; -import { COMPONENTS, HAS_CSS_PROP, STYLES } from '../utils/Symbols'; -import wrapInClass from '../utils/wrapInClass'; import truthy from '../utils/truthy'; +import wrapInClass from '../utils/wrapInClass'; +import { inlineJsx } from './css-prop-inline'; const JSX_IDENTS = Symbol('Astroturf jsx identifiers'); @@ -22,11 +24,6 @@ type CssPropPluginState = PluginState & { }; }; -export const isCreateElementCall = (p: NodePath) => - p.isCallExpression() && - (p.get('callee.property') as any).node && - (p.get('callee.property') as any).node.name === 'createElement'; - function buildCssProp( valuePath: NodePath, name: string | null, @@ -218,14 +215,14 @@ export default { }, JSXAttribute(path: NodePath, state: CssPropPluginState) { - const { file } = state; + const { file, defaultedOptions } = state; if (path.node.name.name !== 'css') return; const valuePath = path.get('value'); - const parentPath = path.findParent(p => p.isJSXOpeningElement()); + const parentPath = path.findParent((p) => p.isJSXOpeningElement()); - const compiledNode = buildCssProp( + const compiledNode: any = buildCssProp( valuePath, parentPath && getNameFromPath(parentPath.get('name') as NodePath), state, @@ -233,7 +230,13 @@ export default { ); if (compiledNode) { - valuePath.replaceWith(compiledNode); + if (defaultedOptions.experiments.inlineCssPropOptimization) { + inlineJsx(path, compiledNode.expression, state); + path.remove(); + } else { + valuePath.replaceWith(compiledNode); + } + file.set(HAS_CSS_PROP, true); } }, diff --git a/src/types.ts b/src/types.ts index 77a2f357..2108347b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,7 @@ export interface ResolvedOptions { experiments: { modularCssExternals?: boolean; selfCompile?: boolean; + inlineCssPropOptimization?: boolean; }; } diff --git a/src/utils/isCreateElementCall.ts b/src/utils/isCreateElementCall.ts new file mode 100644 index 00000000..0c4cec03 --- /dev/null +++ b/src/utils/isCreateElementCall.ts @@ -0,0 +1,12 @@ +import * as T from '@babel/types'; +import { NodePath } from '@babel/core'; + +export default function isCreateElementCall( + p: NodePath, +): p is NodePath { + return ( + p.isCallExpression() && + (p.get('callee.property') as any).node && + (p.get('callee.property') as any).node.name === 'createElement' + ); +} diff --git a/test/css-prop.test.js b/test/css-prop.test.js index f81247ee..a47f84e9 100644 --- a/test/css-prop.test.js +++ b/test/css-prop.test.js @@ -1,7 +1,7 @@ import { mount } from 'enzyme'; import { jsx } from '../src/runtime/jsx'; -import { format, testAllRunners } from './helpers'; +import { format, run, testAllRunners } from './helpers'; describe('css prop', () => { testAllRunners('should compile string', async (runner) => { @@ -208,6 +208,204 @@ describe('css prop', () => { }, ); + describe.only('inline optimization', () => { + async function runInline(jsxStr) { + const [code] = await run( + ` + import { css } from 'astroturf'; + + function Button({ color }) { + return (${jsxStr}); + } + `, + { experiments: { inlineCssPropOptimization: true } }, + ); + + expect(code).not.toContain('css={'); + return code; + } + + it('style no conflicts', async () => { + const code = await runInline(` +