diff --git a/packages/babel-plugin-optimize-react/LICENSE b/packages/babel-plugin-optimize-react/LICENSE new file mode 100644 index 00000000000..188fb2b0bd8 --- /dev/null +++ b/packages/babel-plugin-optimize-react/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-present, Facebook, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/babel-plugin-optimize-react/README.md b/packages/babel-plugin-optimize-react/README.md new file mode 100644 index 00000000000..40ed39e0c04 --- /dev/null +++ b/packages/babel-plugin-optimize-react/README.md @@ -0,0 +1,58 @@ +# babel-plugin-optimize-react + +This Babel 7 plugin optimizes React hooks by transforming common patterns into more effecient output when using with tools such as [Create React App](https://github.com/facebook/create-react-app). For example, with this plugin the following output is optimized as shown: + +```js +// Original +var _useState = Object(react__WEBPACK_IMPORTED_MODULE_1_["useState"])(Math.random()), + _State2 = Object(_Users_gaearon_p_create_rreact_app_node_modules_babel_runtime_helpers_esm_sliceToArray_WEBPACK_IMPORTED_MODULE_0__["default"])(_useState, 1), + value = _useState2[0]; + +// With this plugin +var useState = react__WEBPACK_IMPORTED_MODULE_1_.useState; +var __ref__0 = useState(Math.random()); +var value = __ref__0[0]; +``` + +## Named imports to hooks get transformed + +```js +// Original +import React, {useState} from 'react'; + +// With this plugin +import React from 'react'; +const {useState} = React; +``` + +## Array destructuring transform for React's built-in hooks + +```js +// Original +const [counter, setCounter] = useState(0); + +// With this plugin +const __ref__0 = useState(0); +const counter = __ref__0[0]; +const setCounter = __ref__0[1]; +``` + +## React.createElement becomes a hoisted constant + +```js +// Original +import React from 'react'; + +function MyComponent() { + return React.createElement('div', null, 'Hello world'); +} + +// With this plugin +import React from 'react'; +const __reactCreateElement__ = React.createElement; + +function MyComponent() { + return __reactCreateElement__('div', null, 'Hello world'); +} +``` + diff --git a/packages/babel-plugin-optimize-react/__tests__/__snapshots__/createElement-test.js.snap b/packages/babel-plugin-optimize-react/__tests__/__snapshots__/createElement-test.js.snap new file mode 100644 index 00000000000..60d3cd3c6b2 --- /dev/null +++ b/packages/babel-plugin-optimize-react/__tests__/__snapshots__/createElement-test.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`React createElement transforms should transform React.createElement calls #2 1`] = ` +"const React = require(\\"react\\"); + +const __reactCreateElement__ = React.createElement; +export function MyComponent() { + return __reactCreateElement__(\\"div\\", null, __reactCreateElement__(\\"span\\", null, \\"Hello world!\\")); +}" +`; + +exports[`React createElement transforms should transform React.createElement calls #3 1`] = ` +"const React = require(\\"react\\"); + +const __reactCreateElement__ = React.createElement; + +const node = __reactCreateElement__(\\"div\\", null, __reactCreateElement__(\\"span\\", null, \\"Hello world!\\")); + +export function MyComponent() { + return node; +}" +`; + +exports[`React createElement transforms should transform React.createElement calls 1`] = ` +"import React from \\"react\\"; +const __reactCreateElement__ = React.createElement; +export function MyComponent() { + return __reactCreateElement__(\\"div\\"); +}" +`; diff --git a/packages/babel-plugin-optimize-react/__tests__/__snapshots__/hooks-test.js.snap b/packages/babel-plugin-optimize-react/__tests__/__snapshots__/hooks-test.js.snap new file mode 100644 index 00000000000..e2aef83464f --- /dev/null +++ b/packages/babel-plugin-optimize-react/__tests__/__snapshots__/hooks-test.js.snap @@ -0,0 +1,131 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`React hook transforms should support destructuring hooks from imports #2 1`] = ` +"import React from \\"react\\"; +const __reactCreateElement__ = React.createElement; +const { + useState +} = React; +export function MyComponent() { + const _ref_0 = useState(0); + + const setCounter = _ref_0[1]; + const counter = _ref_0[0]; + return __reactCreateElement__(\\"div\\", null, counter); +}" +`; + +exports[`React hook transforms should support destructuring hooks from imports #3 1`] = ` +"import React from \\"react\\"; +const __reactCreateElement__ = React.createElement; +const useState = React.useState; +export function MyComponent() { + const _ref_0 = useState(0); + + const setCounter = _ref_0[1]; + const counter = _ref_0[0]; + return __reactCreateElement__(\\"div\\", null, counter); +}" +`; + +exports[`React hook transforms should support destructuring hooks from imports #4 1`] = ` +"import React from \\"react\\"; +const __reactCreateElement__ = React.createElement; +const foo = React.useState; +export function MyComponent() { + const _ref_0 = foo(0); + + const setCounter = _ref_0[1]; + const counter = _ref_0[0]; + return __reactCreateElement__(\\"div\\", null, counter); +}" +`; + +exports[`React hook transforms should support destructuring hooks from imports #5 1`] = ` +"import React from \\"react\\"; +const __reactCreateElement__ = React.createElement; +const { + useState: foo +} = React; +export function MyComponent() { + const _ref_0 = foo(0); + + const setCounter = _ref_0[1]; + const counter = _ref_0[0]; + return __reactCreateElement__(\\"div\\", null, counter); +}" +`; + +exports[`React hook transforms should support destructuring hooks from imports 1`] = ` +"import React from \\"react\\"; +const __reactCreateElement__ = React.createElement; +const { + useState +} = React; +export function MyComponent() { + const _ref_0 = useState(0); + + const setCounter = _ref_0[1]; + const counter = _ref_0[0]; + return __reactCreateElement__(\\"div\\", null, counter); +}" +`; + +exports[`React hook transforms should support destructuring hooks from require calls 1`] = ` +"const React = require(\\"react\\"); + +const __reactCreateElement__ = React.createElement; +const { + useState +} = React; +export function MyComponent() { + const _ref_0 = useState(0); + + const setCounter = _ref_0[1]; + const counter = _ref_0[0]; + return __reactCreateElement__(\\"div\\", null, counter); +}" +`; + +exports[`React hook transforms should support hook CJS require with no default 1`] = ` +"const { + useState +} = require(\\"react\\");" +`; + +exports[`React hook transforms should support hook imports with aliasing 1`] = ` +"import React from \\"react\\"; +const { + useState: foo +} = React;" +`; + +exports[`React hook transforms should support hook imports with no default 1`] = ` +"import React from \\"react\\"; +const { + useState +} = React;" +`; + +exports[`React hook transforms should support mixed hook imports 1`] = ` +"import React from \\"react\\"; +import { memo } from \\"react\\"; +const { + useState +} = React;" +`; + +exports[`React hook transforms should support mixed hook imports with no default 1`] = ` +"import React from \\"react\\"; +const { + useState +} = React; +import { memo } from \\"react\\";" +`; + +exports[`React hook transforms should support transform hook imports 1`] = ` +"import React from \\"react\\"; +const { + useState +} = React;" +`; diff --git a/packages/babel-plugin-optimize-react/__tests__/createElement-test.js b/packages/babel-plugin-optimize-react/__tests__/createElement-test.js new file mode 100644 index 00000000000..b2c388834a3 --- /dev/null +++ b/packages/babel-plugin-optimize-react/__tests__/createElement-test.js @@ -0,0 +1,50 @@ +'use strict'; + +const plugin = require('../index.js'); +const babel = require('@babel/core'); + +function transform(code) { + return babel.transform(code, { + plugins: [plugin], + }).code; +} + +describe('React createElement transforms', () => { + it('should transform React.createElement calls', () => { + const test = ` + import React from "react"; + + export function MyComponent() { + return React.createElement("div"); + } + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should transform React.createElement calls #2', () => { + const test = ` + const React = require("react"); + + export function MyComponent() { + return React.createElement("div", null, React.createElement("span", null, "Hello world!")); + } + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should transform React.createElement calls #3', () => { + const test = ` + const React = require("react"); + + const node = React.createElement("div", null, React.createElement("span", null, "Hello world!")); + + export function MyComponent() { + return node; + } + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/babel-plugin-optimize-react/__tests__/hooks-test.js b/packages/babel-plugin-optimize-react/__tests__/hooks-test.js new file mode 100644 index 00000000000..c67046e756c --- /dev/null +++ b/packages/babel-plugin-optimize-react/__tests__/hooks-test.js @@ -0,0 +1,150 @@ +'use strict'; + +const plugin = require('../index.js'); +const babel = require('@babel/core'); + +function transform(code) { + return babel.transform(code, { + plugins: [plugin], + }).code; +} + +describe('React hook transforms', () => { + it('should support transform hook imports', () => { + const test = ` + import React, {useState} from "react"; + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support hook imports with aliasing', () => { + const test = ` + import React, {useState as foo} from "react"; + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support destructuring hooks from imports', () => { + const test = ` + import React, {useState} from "react"; + + export function MyComponent() { + const [counter, setCounter] = useState(0); + + return React.createElement("div", null, counter); + } + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support destructuring hooks from imports #2', () => { + const test = ` + import React from "react"; + const {useState} = React; + + export function MyComponent() { + const [counter, setCounter] = useState(0); + + return React.createElement("div", null, counter); + } + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support destructuring hooks from imports #3', () => { + const test = ` + import React from "react"; + const useState = React.useState; + + export function MyComponent() { + const [counter, setCounter] = useState(0); + + return React.createElement("div", null, counter); + } + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support destructuring hooks from imports #4', () => { + const test = ` + import React from "react"; + const foo = React.useState; + + export function MyComponent() { + const [counter, setCounter] = foo(0); + + return React.createElement("div", null, counter); + } + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support destructuring hooks from imports #5', () => { + const test = ` + import React, {useState as foo} from "react"; + + export function MyComponent() { + const [counter, setCounter] = foo(0); + + return React.createElement("div", null, counter); + } + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support destructuring hooks from require calls', () => { + const test = ` + const React = require("react"); + const {useState} = React; + + export function MyComponent() { + const [counter, setCounter] = useState(0); + + return React.createElement("div", null, counter); + } + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support hook imports with no default', () => { + const test = ` + import {useState} from "react"; + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support hook CJS require with no default', () => { + const test = ` + const {useState} = require("react"); + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support mixed hook imports', () => { + const test = ` + import React from "react"; + import {memo, useState} from "react"; + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); + + it('should support mixed hook imports with no default', () => { + const test = ` + import {useState} from "react"; + import {memo} from "react"; + `; + const output = transform(test); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/babel-plugin-optimize-react/index.js b/packages/babel-plugin-optimize-react/index.js new file mode 100644 index 00000000000..e42efb54eec --- /dev/null +++ b/packages/babel-plugin-optimize-react/index.js @@ -0,0 +1,297 @@ +'use strict'; + +const reactHooks = new Set([ + 'useCallback', + 'useContext', + 'useDebugValue', + 'useEffect', + 'useImperativeHandle', + 'useLayoutEffect', + 'useMemo', + 'useReducer', + 'useRef', + 'useState', +]); + +module.exports = function(babel) { + const { types: t } = babel; + + // Collects named imports of React hooks from the "react" package + function collectReactHooksAndRemoveTheirNamedImports(path, state) { + const node = path.node; + const hooks = []; + if (t.isStringLiteral(node.source) && node.source.value === 'react') { + const specifiers = path.get('specifiers'); + if (state.hasDefaultSpecifier === undefined) { + state.hasDefaultSpecifier = false; + } + + for (let specifier of specifiers) { + if (t.isImportSpecifier(specifier)) { + const importedNode = specifier.node.imported; + const localNode = specifier.node.local; + + if (t.isIdentifier(importedNode) && t.isIdentifier(localNode)) { + if (reactHooks.has(importedNode.name)) { + hooks.push({ + imported: importedNode.name, + local: localNode.name, + }); + specifier.remove(); + } + } + } else if (t.isImportDefaultSpecifier(specifier)) { + state.hasDefaultSpecifier = true; + } + } + // If there is no default specifier for React, add one + if (state.hasDefaultSpecifier === false && specifiers.length > 0) { + const defaultSpecifierNode = t.importDefaultSpecifier( + t.identifier('React') + ); + + path.pushContainer('specifiers', defaultSpecifierNode); + state.hasDefaultSpecifier = true; + } + } + return hooks; + } + + function isReactImport(path) { + if (t.isIdentifier(path)) { + const identifierName = path.node.name; + const binding = path.scope.getBinding(identifierName); + + if (binding !== undefined) { + const bindingPath = binding.path; + + if (t.isImportDefaultSpecifier(bindingPath)) { + const parentPath = bindingPath.parentPath; + + if ( + t.isImportDeclaration(parentPath) && + t.isStringLiteral(parentPath.node.source) && + parentPath.node.source.value === 'react' + ) { + return true; + } + } else if (t.isVariableDeclarator(bindingPath)) { + const init = bindingPath.get('init'); + + if ( + t.isCallExpression(init) && + t.isIdentifier(init.node.callee) && + init.node.callee.name === 'require' && + init.node.arguments.length === 1 && + t.isStringLiteral(init.node.arguments[0]) && + init.node.arguments[0].value === 'react' + ) { + return true; + } + } + } + } + return false; + } + + function isReferencingReactHook(path) { + if (t.isIdentifier(path)) { + const identifierName = path.node.name; + const binding = path.scope.getBinding(identifierName); + + if (binding !== undefined) { + const bindingPath = binding.path; + + if (t.isVariableDeclarator(bindingPath)) { + const init = bindingPath.get('init'); + const bindingId = binding.identifier; + + if (t.isIdentifier(init) && isReactImport(init)) { + if (reactHooks.has(bindingId.name)) { + return true; + } + const id = bindingPath.get('id'); + + if (t.isObjectPattern(id)) { + const properties = id.get('properties'); + + for (let property of properties) { + if ( + t.isObjectProperty(property) && + property.node.value === bindingId && + t.isIdentifier(property.node.key) && + reactHooks.has(property.node.key.name) + ) { + return true; + } + } + } + } else if (t.isMemberExpression(init)) { + const object = init.get('object'); + const property = init.get('property'); + + if ( + isReactImport(object) && + t.isIdentifier(property) && + reactHooks.has(property.node.name) + ) { + return true; + } + } + } + } + } + return false; + } + + function isUsingDestructuredArray(path) { + const parentPath = path.parentPath; + + if (t.isVariableDeclarator(parentPath)) { + const id = parentPath.get('id'); + return t.isArrayPattern(id); + } + return false; + } + + function isCreateReactElementCall(path) { + if (t.isCallExpression(path)) { + const callee = path.get('callee'); + + if (t.isMemberExpression(callee)) { + const object = callee.get('object'); + const property = callee.get('property'); + + if ( + isReactImport(object) && + t.isIdentifier(property) && + property.node.name === 'createElement' + ) { + return true; + } + } + } + } + + function createConstantCreateElementReference(reactReferencePath) { + const identifierName = reactReferencePath.node.name; + const binding = reactReferencePath.scope.getBinding(identifierName); + const createElementReference = t.identifier('__reactCreateElement__'); + const createElementDeclaration = t.variableDeclaration('const', [ + t.variableDeclarator( + createElementReference, + t.memberExpression(t.identifier('React'), t.identifier('createElement')) + ), + ]); + const bindingPath = binding.path; + + if ( + t.isImportDefaultSpecifier(bindingPath) || + t.isVariableDeclarator(bindingPath) + ) { + bindingPath.parentPath.insertAfter(createElementDeclaration); + // Make sure we declare our new now so scope tracking continues to work + const reactElementDeclarationPath = bindingPath.parentPath.getNextSibling(); + reactReferencePath.scope.registerDeclaration(reactElementDeclarationPath); + } + return createElementReference; + } + + return { + name: 'babel-plugin-optimize-react', + visitor: { + ImportDeclaration(path, state) { + // Collect all hooks that are named imports from the React package. i.e.: + // import React, {useState} from "react"; + // As we collection them, we also remove the imports from the declaration. + + const importedHooks = collectReactHooksAndRemoveTheirNamedImports( + path, + state + ); + if (importedHooks.length > 0) { + // Create a destructured variable declaration. i.e.: + // const {useEffect, useState} = React; + // Then insert it below the import declaration node. + + const declarations = t.variableDeclarator( + t.objectPattern( + importedHooks.map(({ imported, local }) => + t.objectProperty( + t.identifier(imported), + t.identifier(local), + false, + imported === local + ) + ) + ), + t.identifier('React') + ); + const hookDeclarationNode = t.variableDeclaration('const', [ + declarations, + ]); + path.insertAfter(hookDeclarationNode); + // Make sure we declare our new now so scope tracking continues to work + const hookDeclarationPath = path.getNextSibling(); + path.scope.registerDeclaration(hookDeclarationPath); + } + }, + CallExpression(path, state) { + if (state.destructuredCounter === undefined) { + state.destructuredCounter = 0; + } + const calleePath = path.get('callee'); + + // Ensure we found a primitive React hook that is using a destructuring array pattern + if ( + isUsingDestructuredArray(path) && + isReferencingReactHook(calleePath) + ) { + const parentPath = path.parentPath; + + if (t.isVariableDeclarator(parentPath)) { + const id = parentPath.get('id'); + const elements = id.get('elements'); + const kind = parentPath.parentPath.node.kind; + // Replace the array destructure pattern with a reference node. + + const referenceNode = t.identifier( + '_ref_' + state.destructuredCounter++ + ); + id.replaceWith(referenceNode); + // Now insert references to the reference node, i.e.: + // const counter = __ref__[0]; + + let arrayIndex = 0; + for (let element of elements) { + const arrayAccessNode = t.variableDeclaration(kind, [ + t.variableDeclarator( + element.node, + t.memberExpression( + referenceNode, + t.numericLiteral(arrayIndex++), + true + ) + ), + ]); + parentPath.parentPath.insertAfter(arrayAccessNode); + // Make sure we declare our new now so scope tracking continues to work + const arrayAccessPath = path.getNextSibling(); + path.scope.registerDeclaration(arrayAccessPath); + } + } + } else if (isCreateReactElementCall(path)) { + const callee = path.get('callee'); + const reactReferencePath = callee.get('object'); + + if (state.createElementReference === undefined) { + state.createElementReference = createConstantCreateElementReference( + reactReferencePath + ); + } + callee.replaceWith(state.createElementReference); + } + }, + }, + }; +}; diff --git a/packages/babel-plugin-optimize-react/package.json b/packages/babel-plugin-optimize-react/package.json new file mode 100644 index 00000000000..b51d78d4179 --- /dev/null +++ b/packages/babel-plugin-optimize-react/package.json @@ -0,0 +1,24 @@ +{ + "name": "babel-plugin-optimize-react", + "version": "0.0.3", + "description": "Babel plugin for optimizing common React patterns", + "repository": "facebookincubator/create-react-app", + "license": "MIT", + "bugs": { + "url": "https://github.com/facebookincubator/create-react-app/issues" + }, + "main": "index.js", + "files": [ + "index.js" + ], + "peerDependencies": { + "@babel/core": "^7.1.0" + }, + "devDependencies": { + "jest": "^23.6.0", + "prettier": "^1.15.3" + }, + "scripts": { + "test": "jest" + } +}