diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..69ff739 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +/demo diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..de4de58 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Run demo", + "preLaunchTask": "npm: build", + "program": "${workspaceFolder}/node_modules/.bin/babel", + "cwd": "${workspaceFolder}/demo", + "args": ["demoComponents.js"] + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..1a64ce7 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,15 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "build", + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} diff --git a/demo/babel.config.js b/demo/babel.config.js new file mode 100644 index 0000000..68dc30b --- /dev/null +++ b/demo/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + plugins: [[require('../lib'), { pure: true }]], +} diff --git a/demo/demoComponents.js b/demo/demoComponents.js new file mode 100644 index 0000000..f593eb0 --- /dev/null +++ b/demo/demoComponents.js @@ -0,0 +1,12 @@ +import React from 'react' +import styled from 'styled-components' + +const Wrapper = styled.div` + color: blue; +` + +export function MyComponent() { + return React.createElement(Wrapper) +} +MyComponent.displayName = 'FancyName1' +MyComponent.defaultProps = {} diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..0c5a892 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,6 @@ +{ + "name": "babel-pugin-styled-components-demo", + "scripts": { + "transpile": "../node_modules/.bin/babel demoComponents.js" + } +} diff --git a/package.json b/package.json index 28a988b..f2c6e51 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,9 @@ "scripts": { "clean": "rimraf lib", "style": "prettier --write src/**/*.js", + "prebuild": "yarn clean", "build": "babel src -d lib", + "watch": "yarn build -w", "test": "jest", "test:watch": "npm run test -- --watch", "prepublish": "npm run clean && npm run build" diff --git a/src/index.js b/src/index.js index e1f1cf5..6d725a9 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,12 @@ import syntax from 'babel-plugin-syntax-jsx' import pureAnnotation from './visitors/pure' +import pureInlineCalculations from './visitors/pureInlineCalculations' import minify from './visitors/minify' import displayNameAndId from './visitors/displayNameAndId' import templateLiterals from './visitors/templateLiterals' import assignStyledRequired from './visitors/assignStyledRequired' import transpileCssProp from './visitors/transpileCssProp' +import pureWrapStaticProps from './visitors/pureWrapStaticProps' export default function({ types: t }) { return { @@ -28,11 +30,22 @@ export default function({ types: t }) { pureAnnotation(t)(path, state) }, TaggedTemplateExpression(path, state) { + pureInlineCalculations(t)(path, state) minify(t)(path, state) displayNameAndId(t)(path, state) templateLiterals(t)(path, state) pureAnnotation(t)(path, state) }, + FunctionDeclaration(path, state) { + // technically this is more like, + // "mark pure if it's a function component that consumes a styled component and also has static properties", + // but that's rather long ;) + pureWrapStaticProps(t)(path, state) + }, + VariableDeclarator(path, state) { + // same thing for arrow functions + pureWrapStaticProps(t)(path, state) + }, }, } } diff --git a/src/utils/detectors.js b/src/utils/detectors.js index d2da8cb..8c107c4 100644 --- a/src/utils/detectors.js +++ b/src/utils/detectors.js @@ -56,7 +56,24 @@ export const importLocalName = (name, state, bypassCache = false) => { return localName } -export const isStyled = t => (tag, state) => { +// cache styled tags that we've already found from previous calls to isStyled() +const visitedStyledTags = new WeakSet() + +export const isStyled = t => (tag, state, includeIIFE = false) => { + if (includeIIFE) { + // check to see if this is an IIFE wrapper created by pureWrapStaticProps() + // that replaced what was originally a `styled` call + if (t.isArrowFunctionExpression(tag) && tag.body && tag.body.body[0]) { + const statement = tag.body.body[0] + if (t.isVariableDeclaration(statement)) { + const callee = statement.declarations[0].init.callee + if (callee && isStyled(t)(callee, state)) { + return true + } + } + } + } + if ( t.isCallExpression(tag) && t.isMemberExpression(tag.callee) && @@ -64,10 +81,13 @@ export const isStyled = t => (tag, state) => { ) { // styled.something() return isStyled(t)(tag.callee.object, state) - } else { - return ( - (t.isMemberExpression(tag) && - tag.object.name === importLocalName('default', state)) || + } + if (visitedStyledTags.has(tag)) { + return true + } + const ret = Boolean( + (t.isMemberExpression(tag) && + tag.object.name === importLocalName('default', state)) || (t.isCallExpression(tag) && tag.callee.name === importLocalName('default', state)) || /** @@ -87,8 +107,11 @@ export const isStyled = t => (tag, state) => { t.isMemberExpression(tag.callee) && tag.callee.property.name === 'default' && tag.callee.object.name === state.styledRequired) - ) + ) + if (ret) { + visitedStyledTags.add(tag) } + return ret } export const isCSSHelper = t => (tag, state) => @@ -108,10 +131,14 @@ export const isWithThemeHelper = t => (tag, state) => t.isIdentifier(tag) && tag.name === importLocalName('withTheme', state) export const isHelper = t => (tag, state) => - isCSSHelper(t)(tag, state) || isKeyframesHelper(t)(tag, state) || isWithThemeHelper(t)(tag, state) + isCSSHelper(t)(tag, state) || + isKeyframesHelper(t)(tag, state) || + isWithThemeHelper(t)(tag, state) export const isPureHelper = t => (tag, state) => isCSSHelper(t)(tag, state) || isKeyframesHelper(t)(tag, state) || isCreateGlobalStyleHelper(t)(tag, state) || isWithThemeHelper(t)(tag, state) + +export { isFunctionComponent } from './isFunctionComponent' diff --git a/src/utils/isFunctionComponent.js b/src/utils/isFunctionComponent.js new file mode 100644 index 0000000..94506c8 --- /dev/null +++ b/src/utils/isFunctionComponent.js @@ -0,0 +1,136 @@ +/** + * Adapted from https://github.com/oliviertassinari/babel-plugin-transform-react-remove-prop-types/blob/master/src/isStatelessComponent.js + */ + +import { isStyled } from '../utils/detectors' + +const traversed = Symbol('traversed') + +function isJSXElementOrReactCreateElement( + path, + filterFn = null // optional filter function to match only certain kinds of React elements +) { + let visited = false + + path.traverse({ + CallExpression(path2) { + const callee = path2.get('callee') + + if ( + callee.matchesPattern('React.createElement') || + callee.matchesPattern('React.cloneElement') || + callee.node.name === 'cloneElement' + ) { + if (visited) { + return + } + visited = filterFn ? filterFn(path2) : true + } + }, + JSXElement(path2) { + if (visited) { + return + } + visited = filterFn ? filterFn(path2) : true + }, + }) + + return visited +} + +function isReturningJSXElement(path, state, filterFn = null, iteration = 0) { + // Early exit for ArrowFunctionExpressions, there is no ReturnStatement node. + if ( + path.node.init && + path.node.init.body && + isJSXElementOrReactCreateElement(path, filterFn) + ) { + return true + } + + if (iteration > 20) { + throw Error('babel-plugin-styled-components: infinite loop detected.') + } + + let visited = false + + path.traverse({ + ReturnStatement(path2) { + // We have already found what we are looking for. + if (visited) { + return + } + + const argument = path2.get('argument') + + // Nothing is returned + if (!argument.node) { + return + } + + if (isJSXElementOrReactCreateElement(path2, filterFn)) { + visited = true + return + } + + if (argument.node.type === 'CallExpression') { + const name = argument.get('callee').node.name + const binding = path.scope.getBinding(name) + + if (!binding) { + return + } + + // Prevents infinite traverse loop. + if (binding.path[traversed]) { + return + } + + binding.path[traversed] = true + + if ( + isReturningJSXElement(binding.path, state, filterFn, iteration + 1) + ) { + visited = true + } + } + }, + }) + + return visited +} + +/** + * IMPORTANT: This function assumes that the given path is a VariableDeclarator or FunctionDeclaration, + * and will return false positives otherwise. If a more robust version is needed in the future, + * see https://github.com/oliviertassinari/babel-plugin-transform-react-remove-prop-types/blob/master/src/isStatelessComponent.js + * + * Returns true if the given path is a React function component definition + * @param {Path} path + */ +export function isFunctionComponent( + path, + state, + types, + mustConsumeStyledComponent = false // only return true if the component might render a styled component +) { + let filterFn + if (mustConsumeStyledComponent) { + filterFn = reactElementPath => { + // look up the component and check if it's a styled component + const componentId = reactElementPath.isJSXElement() + ? reactElementPath.node.openingElement.name + : reactElementPath.node.arguments[0] + const binding = reactElementPath.scope.getBinding(componentId.name) + if (binding && binding.path.isVariableDeclarator()) { + const { init } = binding.path.node + if (isStyled(types)(init.callee, state, true /* includeIIFE */)) { + return true + } + } + return false + } + } + + return isReturningJSXElement(path, state, filterFn) +} diff --git a/src/visitors/pure.js b/src/visitors/pure.js index 8c9626d..3650a63 100644 --- a/src/visitors/pure.js +++ b/src/visitors/pure.js @@ -2,6 +2,7 @@ import annotateAsPure from '@babel/helper-annotate-as-pure' import { usePureAnnotation } from '../utils/options' import { isStyled, isPureHelper } from '../utils/detectors' +import pureWrapStaticProps from './pureWrapStaticProps' export default t => (path, state) => { if (usePureAnnotation(state)) { @@ -15,6 +16,11 @@ export default t => (path, state) => { path.parent.type === 'TaggedTemplateExpression' ) { annotateAsPure(path) + if (path.parent.type === 'VariableDeclarator') { + // if static properties were added to the styled component (e.g. `defaultProps`), + // also wrap it in an IIFE and add a PURE comment to the IIFE + pureWrapStaticProps(t)(path.parentPath, state, true) + } } } } diff --git a/src/visitors/pureInlineCalculations.js b/src/visitors/pureInlineCalculations.js new file mode 100644 index 0000000..20c0082 --- /dev/null +++ b/src/visitors/pureInlineCalculations.js @@ -0,0 +1,39 @@ +import annotateAsPure from '@babel/helper-annotate-as-pure' + +import { usePureAnnotation } from '../utils/options' +import { isStyled } from '../utils/detectors' + +/* + Calculations inside a template literal passed to styled-components can break tree-shaking when using terser, e.g.: + ` + font-size: ${helper(2)}; + width: ${`50${widthUnits}`}; + ` + So we add PURE comments to any such calls. + + NB: This means that any helper functions used inside your styles should be side-effect free. + In practical terms it's probably only an issue if the side-effect affects whether or not the component + should be included in your bundle, but avoiding side effects in helper functions is a good practice + anyhow. + + Another note: Assuming the `transpileTemplateLiterals` option is enabled, it seems that rollup's tree-shaking + algorithm works fine without running this function. It seems to only be needed for terser. + */ +export default t => (path, state) => { + if (!usePureAnnotation(state)) { + return + } + if (isStyled(t)(path.node.tag, state)) { + // loop through any ${} expressions inside the template + for (const expr of path.node.quasi.expressions) { + if ( + t.isCallExpression(expr) || + // template literals (which get transpiled to function calls when transpiling to ES5) + // can break tree-shaking too + t.isTemplateLiteral(expr) + ) { + annotateAsPure(expr) + } + } + } +} diff --git a/src/visitors/pureWrapStaticProps.js b/src/visitors/pureWrapStaticProps.js new file mode 100644 index 0000000..527c2cf --- /dev/null +++ b/src/visitors/pureWrapStaticProps.js @@ -0,0 +1,113 @@ +import annotateAsPure from '@babel/helper-annotate-as-pure' +import template from '@babel/template' + +import { usePureAnnotation } from '../utils/options' +import { isFunctionComponent } from '../utils/detectors' + +const buildIIFE = template('(() => {BODY})();') + +const visited = new WeakSet() + +// For styled components and components that consume styled components, static properties can break tree-shaking +// (see https://github.com/styled-components/babel-plugin-styled-components/issues/245). So we check for static +// properties and wrap the component in an IIFE in that case, and add the PURE comment to the IIFE. +export default t => (componentPath, state, isStyledComponent = false) => { + if ( + !usePureAnnotation(state) || + (!isStyledComponent && + !isFunctionComponent( + componentPath, + state, + t, + true /* mustConsumeStyledComponent */ + )) || + visited.has(componentPath.node) // this prevents the replaceWith() call below from potentially causing an infinite loop + ) { + return + } + visited.add(componentPath.node) + + const componentNameIdentifier = componentPath.node.id + const staticProperties = [] + + // Look for static property assignments: + // For unexported function declarations, we look at siblings of the function node itself. + // For arrow functions, exported functions, and styled components, we need to look at siblings of the + // closest parent Statement. + const statement = + componentPath.parentPath && !componentPath.parentPath.isProgram() + ? componentPath.getStatementParent() + : componentPath + checkSiblingsForStaticProps(statement) + + if (staticProperties.length) { + replaceComponentWithIIFE() + } + + // Check sibling nodes to see if any of them are static properties (e.g. displayName or defaultProps) + function checkSiblingsForStaticProps(startPath) { + if (!startPath.inList) { + return + } + for ( + let nextSibling = startPath.getSibling(startPath.key + 1); + nextSibling.node !== undefined; + nextSibling = startPath.getSibling(nextSibling.key + 1) + ) { + if (nextSibling.isExpressionStatement()) { + const { expression } = nextSibling.node + if ( + t.isAssignmentExpression(expression) && + t.isMemberExpression(expression.left) && + expression.operator === '=' && + expression.left.object.name === componentNameIdentifier.name + ) { + staticProperties.push(nextSibling.node) + // remove the static property assignment; we'll re-add it inside the IIFE at the end + nextSibling.remove() + } + } + } + } + + // Wrap the function and its static properties in an IIFE and add the PURE comment + function replaceComponentWithIIFE() { + // get or construct the component node to put inside the IIFE depending on whether it's a + // function declaration or variable declaration (i.e. styled component or arrow function) + const componentNode = componentPath.isVariableDeclarator() + ? t.variableDeclaration('const', [componentPath.node]) + : componentPath.node + + const componentAndStaticProperties = [ + componentNode, + ...staticProperties, + t.returnStatement(componentNameIdentifier), + ] + + const iife = buildIIFE({ + BODY: componentAndStaticProperties, + }) + // add the PURE comment + annotateAsPure(iife.expression) + + // replace the original function node with the IIFE + const declarator = t.variableDeclarator( + componentNameIdentifier, + iife.expression + ) + + const { parentPath } = componentPath + if (parentPath.isExportDefaultDeclaration()) { + parentPath.replaceWith(t.variableDeclaration('const', [declarator])) + parentPath.insertAfter( + t.exportDefaultDeclaration(componentNameIdentifier) + ) + } else { + componentPath.replaceWith( + componentPath.isStatement() + ? t.variableDeclaration('const', [declarator]) + : declarator + ) + } + } +} diff --git a/test/fixtures/annotate-inline-calculations-with-pure-comments/.babelrc b/test/fixtures/annotate-inline-calculations-with-pure-comments/.babelrc new file mode 100644 index 0000000..8232aac --- /dev/null +++ b/test/fixtures/annotate-inline-calculations-with-pure-comments/.babelrc @@ -0,0 +1,10 @@ +{ + "plugins": [ + [ + "../../../src", + { + "pure": true + } + ] + ] +} diff --git a/test/fixtures/annotate-inline-calculations-with-pure-comments/code.js b/test/fixtures/annotate-inline-calculations-with-pure-comments/code.js new file mode 100644 index 0000000..ace4df7 --- /dev/null +++ b/test/fixtures/annotate-inline-calculations-with-pure-comments/code.js @@ -0,0 +1,15 @@ +import styled from 'styled-components' + +export const Component = styled.div` + color: inherit; +` + +const helper = size => `${size}rem` + +const widthUnits = 'px' + +const OtherComponent = styled(Component)` + font-size: ${helper(2)}; + width: ${`50${widthUnits}`}; + height: ${p => helper(p.height)}; +` diff --git a/test/fixtures/annotate-inline-calculations-with-pure-comments/output.js b/test/fixtures/annotate-inline-calculations-with-pure-comments/output.js new file mode 100644 index 0000000..1a140f8 --- /dev/null +++ b/test/fixtures/annotate-inline-calculations-with-pure-comments/output.js @@ -0,0 +1,21 @@ +import styled from 'styled-components'; +export const Component = +/*#__PURE__*/ +styled.div.withConfig({ + displayName: "code__Component", + componentId: "sc-1dsyazt-0" +})(["color:inherit;"]); + +const helper = size => `${size}rem`; + +const widthUnits = 'px'; +const OtherComponent = +/*#__PURE__*/ +styled(Component).withConfig({ + displayName: "code__OtherComponent", + componentId: "sc-1dsyazt-1" +})(["font-size:", ";width:", ";height:", ";"], +/*#__PURE__*/ +helper(2), +/*#__PURE__*/ +`50${widthUnits}`, p => helper(p.height)); diff --git a/test/fixtures/wrap-static-props-with-iife/.babelrc b/test/fixtures/wrap-static-props-with-iife/.babelrc new file mode 100644 index 0000000..8232aac --- /dev/null +++ b/test/fixtures/wrap-static-props-with-iife/.babelrc @@ -0,0 +1,10 @@ +{ + "plugins": [ + [ + "../../../src", + { + "pure": true + } + ] + ] +} diff --git a/test/fixtures/wrap-static-props-with-iife/code.js b/test/fixtures/wrap-static-props-with-iife/code.js new file mode 100644 index 0000000..0781636 --- /dev/null +++ b/test/fixtures/wrap-static-props-with-iife/code.js @@ -0,0 +1,13 @@ +import React from 'react' +import styled from 'styled-components' + +const Wrapper = styled.div` + color: blue; +` +Wrapper.defaultProps = {} + +export function FunctionComponent() { + return +} +FunctionComponent.displayName = 'FancyName1' +FunctionComponent.defaultProps = {} diff --git a/test/fixtures/wrap-static-props-with-iife/output.js b/test/fixtures/wrap-static-props-with-iife/output.js new file mode 100644 index 0000000..482b2b8 --- /dev/null +++ b/test/fixtures/wrap-static-props-with-iife/output.js @@ -0,0 +1,27 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Wrapper = +/*#__PURE__*/ +(() => { + const Wrapper = + /*#__PURE__*/ + styled.div.withConfig({ + displayName: "code__Wrapper", + componentId: "e0so0f-0" + })(["color:blue;"]); + Wrapper.defaultProps = {}; + return Wrapper; +})(); + +export const FunctionComponent = +/*#__PURE__*/ +(() => { + function FunctionComponent() { + return ; + } + + FunctionComponent.displayName = 'FancyName1'; + FunctionComponent.defaultProps = {}; + return FunctionComponent; +})();