-
-
Notifications
You must be signed in to change notification settings - Fork 26.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adds Babel plugin babel-plugin-optimize-react #6219
base: main
Are you sure you want to change the base?
Conversation
It doesn't pick up the correct reference to React when default import is not used (e.g. non-JSX modules). Let's say you have the following module: import { useRef, useEffect } from 'react';
export function usePrevious<T>(value: T) {
const ref: React.MutableRefObject<T | null> = useRef<T>(null);
useEffect(() => {
ref.current = value;
});
return ref.current;
} Current output is: // EXTERNAL MODULE: ./node_modules/react/index.js
var react = __webpack_require__(602);
var react_default = /*#__PURE__*/__webpack_require__.n(react);
// ...
var _React = React,
useRef = _React.useRef,
useEffect = _React.useEffect;
function usePrevious(value) {
var ref = useRef(null);
useEffect(function () {
ref.current = value;
});
return ref.current;
} Expected output: // EXTERNAL MODULE: ./node_modules/react/index.js
var react = __webpack_require__(602);
var react_default = /*#__PURE__*/__webpack_require__.n(react);
// ...
var useRef = react_default.useRef,
useEffect = react_default.useEffect;
function usePrevious(value) {
var ref = useRef(null);
useEffect(function () {
ref.current = value;
});
return ref.current;
} |
@apostolos I've published a new version to NPM – let me know if that fixes your issue. |
@trueadm 0.0.2 fixed the issue with the default import, thanks! I've found one more issue, although minor. The following is currently broken: import React from 'react';
import { memo } from 'react'; I get the following error: ModuleParseError: Module parse failed: Identifier 'React' has already been declared (3:15)
You may need an appropriate loader to handle this file type.
| import React from 'react';
| var __reactCreateElement__ = React.createElement;
> import { memo, React } from 'react'; The question is why bother since you can import both in one statement. It will probably cause issue with TypeScript users that don't use import * as React from 'react';
import { useRef, useEffect, memo } from 'react'; |
We definitely want to support all kinds of imports, thanks for reporting. |
(We should also check that |
@apostolos Thanks for reporting, I'll fix that. Update: fix published, you can find it on NPM now. |
const babel = require('@babel/core'); | ||
|
||
function transform(code) { | ||
return babel.transform(code, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is gonna load babelrc / babel.config.js, not sure if you want that here
also - would be good to have tests both with & without @babel/plugin-transform-destructuring
(and possibly some other overlapping combinations)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see you have no babelrc files in this package, but probably better disable config loading with
babel.transform(code, {
babelrc: false,
configFile: false,
plugins: [plugin],
}).code
just to be more future proof
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call on testing with other plugins. Destructuring optimization seems to be ignored when used alongside preset-env
: optimize-react + preset-env.
I'll work on a PR to this one. At least with a test, hopefully with a fix.
function createConstantCreateElementReference(reactReferencePath) { | ||
const identifierName = reactReferencePath.node.name; | ||
const binding = reactReferencePath.scope.getBinding(identifierName); | ||
const createElementReference = t.identifier('__reactCreateElement__'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this optimization is OK, but it works on per module (file) basis - it doesnt take into account that production bundles use scope hoisting, maybe we could somehow end up with single constant reference per chunk instead of many?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That would be good, but I was unsure how to do that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't hardcode the identifier name, use path.scope.generateUidIdentifier in Program and save its value on state (the second argument to a visitor, which also is === this)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess having a hardcoded name actually does let what Andarist suggested work, but not in a way that works with a minifier. You'd have to do var ref = ref || React.createElement
over and over and you can't prove to a minifier the result of that expression is guaranteed truthy. Scope hoisting would rename the local vars anyway.
The only surefire way to get this working with scope hoisting is to have an ESM export of React. It is possible to generate one with a webpack plugin, and then use a Babel plugin to rewrite imports of React to import the generated ESM wrapper. (e.g. rewrite "from 'react'" to be "from 'react-esm-loader!react'")
This way, you could ensure that only other ESM would be importing the shared React ESM, and scope hoisting would do its job.
That'd stop working as well if there are dynamic imports, though, as webpack would be forced to put the modules shared with more than one chunk in a separate, non-scope-hoisted module. Perhaps another webpack plugin pass could detect this case and generate one wrapper module per chunk.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't enable scope hoisting in CRA. Tbh I think it's reasonable compromise for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't enable scope hoisting in CRA. Tbh I think it's reasonable compromise for now.
Are you sure? I haven't checked it, but it's enabled by default in webpack@4 in production mode (which is used by CRA). https://webpack.js.org/plugins/module-concatenation-plugin/
That would be good, but I was unsure how to do that?
Me neither 😅 Would have to move this rewrite to other phase than transpilation.
I think I've found an edge case: This module defines an FC that passes its The import looked like this: import { memo, useRef, useEffect } from 'react'; But it breaks like in the first case ( I found the following 3 workarounds: // 1
import React, { useRef, useEffect } from 'react';
const { memo } = React;
export const Portal = memo(/*...*/);
// 2
import React, { useRef, useEffect } from 'react';
export const Portal = React.memo(/*...*/);
// 3
import React, { memo, useRef, useEffect } from 'react';
export const Portal = memo(/*...render at least some JSX here...*/); Important detail: If |
Gave it a try and it appears not to be working with namespace imports. I cloned your branch and added this test: it('should transform React.createElement calls #4', () => {
const test = `
import * as React from "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();
}); Which results in the following snapshot: exports[`React createElement transforms should transform React.createElement calls #4 1`] = `
"import * as React, React from \\"react\\";
const node = React.createElement(\\"div\\", null, React.createElement(\\"span\\", null, \\"Hello world!\\"));
export function MyComponent() {
return node;
}"
`; Which explains the syntax errors I was getting. |
@apostolos @vincentriemer Thanks for the bug reports. I'll fix them tomorrow. If you want to get involved though – feel free to make a PR against my forked React repro. Any help would be grateful :) |
@artemirq Many of those plugins are still relevant but not in the scope right now for this plugin. We may expand this scope in the future. |
@vincentriemer @apostolos I've released a new version of the plugin to NPM. Please let me know if you find anymore issues. Thanks for the great help! |
@trueadm Fixes all remaining issues for me (also tested |
@apostolos Awesome stuff. Did you notice and differences compared to before (bundle size, performance)? |
Did a quick comparison on lwjgl.org (latest React alpha, uses only function components with hooks, no classes). I've included react-local which does similar optimizations (although, afaik, without the hook transformation): All routes with content
/customize route (more app-like)
EDIT: Source code available here: https://github.com/LWJGL/lwjgl3-www/tree/master/client |
@apostolos Thanks for checking. :) The differences are all very negligible indeed! |
To be fair that example uses TS so I'm not sure it's directly comparable to our current setup. |
This is great, but doesn't appear to play nicely with
module.exports = {
presets: [
'@babel/react',
['@babel/preset-env', { modules: false, targets: 'ie>=11' }],
],
plugins: ['optimize-react'],
};
import React, { useState } from 'react';
function App() {
let [count, setCount] = useState(0);
return <div onClick={() => setCount(count + 1)}>{count}</div>;
} output from function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); }
function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance"); }
function _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; }
function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
import React from 'react';
var __reactCreateElement__ = React.createElement;
var useState = React.useState;
function App() {
var _useState = useState(0),
_useState2 = _slicedToArray(_useState, 2),
count = _useState2[0],
setCount = _useState2[1];
return __reactCreateElement__("div", {
onClick: function onClick() {
return setCount(count + 1);
}
}, count);
} In this output, it doesn't look like the array destructuring transform happened. If I comment out the line in import React from 'react';
const __reactCreateElement__ = React.createElement;
const {
useState
} = React;
function App() {
let _ref_0 = useState(0);
let setCount = _ref_0[1];
let count = _ref_0[0];
return __reactCreateElement__("div", {
onClick: () => setCount(count + 1)
}, count);
} |
Mostly functional components, tons of hooks. Parsed size become lower, gzipped is bigger: Without `babel-plugin-optimize-react`Parsed
Gzipped
With `babel-plugin-optimize-react`Parsed
Gzipped
|
@umidbekkarimov Overall that looks to be a general positive win. Gzip size was marginally down but parsed time is where it really went up. Also Brotli compression should further improve over gzip in the cases where the plugin was enabled. |
const {useState} = React; | ||
``` | ||
|
||
## Array destructuring transform for React's built-in hooks |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be configurable or ideally communicate with targets/browserslists/@babel/preset-env
. Array spread syntax is supported by all evergreen browsers and that for quite some time now. The optimization is only interesting if you need to support IE11. See example preset-env without IE11
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We opted to do this because the runtime performance of spread syntax was considerably slower in all browsers when I tests this compared to the transformed version. This might have changed since, as this plugin was created 7 months ago.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense. Not sure what the current status of destructuring optimizations in v8 but they had plans to improve it a few months ago. Do you have some numbers about the performance gain?
-- mui/material-ui#16072 (comment) With |
@trueadm Wanted to check in on the status of this getting into CRA. Its certainly an interesting optimization. Also wanted to make a note where we ran into an issue with the usage of a lowercase
transforms to
|
@Friss I don't believe this will be going in. The gains weren't really there and there wasn't much appetite from folks either. |
Maybe we can just get in the getter fix. https://twitter.com/sebmarkbage/status/1250284377138802689?s=21 |
This PR adds a Babel 7 plugin that aims to optimize certain React patterns that aren't as optimized as they might be. For example, with this plugin the following output is optimized as shown:
Named imports for React get transformed
Array destructuring transform for React's built-in hooks
React.createElement becomes a hoisted constant