In some cases, the Hermes bundler produces a larger bundle with Esbuild than with Metro.
What happened?
Due to certain esbuild optimizations, Hermes generates more bytecode compared to the standard Metro bundle.
It hepens for me with the folowing case:
I use styled-components and react-native-gesture-handler and found that then more I have react-native-gesture-handler components wrapped into styled the more my hermes output bundle.
It seems the problem not in the styled-components and react-native-gesture-handler themself but more esbuild and hermes.
The problem can be boiled down to the following code:
// TouchableOpacity.tsx
let Reanimated;
try {
Reanimated = require('react-native-reanimated');
} catch (e) {
Reanimated = undefined;
}
export const TouchableOpacity = ({ children }) => {
Reanimated.useSharedValue(0);
return children;
};
// index.tsx
const { TouchableOpacity } from './TouchableOpacity'
const styled = (Component: React.ComponentType) => (styles: any) => {
return (props: any) => <Component {...props} style={styles} />;
};
// input
const Comp = styled(View)``;
// output
// var _a;
// const Comp = styled(View)(_a || (_a = null));
It seems that the combination of _a || (_a = null) and (this part in the react-native-gesture-handler lib ) are contributing to the large bundle size.
Affected Package
@rnx-kit/metro-serializer-esbuild
Version
0.2.0
Which platforms are you seeing this issue on?
- [X] Android
- [X] iOS
- [ ] macOS
- [ ] Windows
System Information
System:
OS: macOS 14.5
CPU: (10) arm64 Apple M1 Pro
Memory: 1.89 GB / 32.00 GB
Shell:
version: "5.9"
path: /bin/zsh
Binaries:
Node:
version: 22.7.0
path: ~/.asdf/installs/nodejs/22.7.0/bin/node
Yarn:
version: 3.6.4
path: /opt/homebrew/bin/yarn
npm:
version: 10.8.2
path: ~/.asdf/plugins/nodejs/shims/npm
Watchman: Not Found
SDKs:
iOS SDK:
Platforms:
- DriverKit 23.5
- iOS 17.5
- macOS 14.5
- tvOS 17.5
- visionOS 1.2
- watchOS 10.5
Android SDK:
API Levels:
- "30"
- "31"
- "32"
- "33"
- "34"
Build Tools:
- 30.0.2
- 30.0.3
- 31.0.0
- 33.0.0
- 33.0.1
- 33.0.2
- 34.0.0
System Images:
- android-23 | Google APIs ARM 64 v8a
- android-29 | Google APIs ARM 64 v8a
- android-30 | Google APIs ARM 64 v8a
- android-31 | ARM 64 v8a
- android-31 | Google APIs ARM 64 v8a
- android-31 | Google Play ARM 64 v8a
- android-32 | Google APIs ARM 64 v8a
- android-33 | Google APIs ARM 64 v8a
Android NDK: Not Found
IDEs:
Android Studio: 2024.1 AI-241.18034.62.2411.12169540
Xcode:
version: 15.4/15F31d
path: /usr/bin/xcodebuild
Languages:
Java:
version: 17.0.10
Ruby:
version: 3.0.3
npmPackages:
"@react-native-community/cli": Not Found
react:
installed: 18.3.1
wanted: 18.3.1
react-native:
installed: 0.75.3
wanted: 0.75.3
react-native-macos: Not Found
npmGlobalPackages:
"*react-native*": Not Found
Android:
hermesEnabled: true
newArchEnabled: false
iOS:
hermesEnabled: Not found
newArchEnabled: false
Steps to Reproduce
- Clone https://github.com/Augustach/esbuild-hermes-issue
- Run the script
yarn copy-modules. This will generate 1000 copies of the./src/template.tsxfile. - Run
ESBUILD=true yarn buildto create the esbuild bundle. - Check the size of the
./output/index.android.bundle.hrsHermes bundle. - Run
ESBUILD=true yarn buildto create the metro bundle. - Check the size of the
./output/index.android.bundle.hrsHermes bundle.
Expected Result (ER): The size of the esbuild bundle should be less than or equal to the size of the Metro bundle.
Actual Result (AR): The size of the esbuild bundle (~2.7MB) is much larger than the bundle (~1.3MB) produced by Metro.
Code of Conduct
- [X] I agree to follow this project's Code of Conduct
Thanks for reporting this, @Augustach!
I think this comes down to esbuild enabling lowering of template literals because Hermes only partially supports it (depending on the version, it might be call site caching or .toString() according to https://compat-table.github.io/compat-table/es6/). Disabling the lowering makes esbuild preserve them, thus outputting a smaller bundle.
Can you try adding "template-literal": true to the table in node_modules/@rnx-kit/metro-serializer-esbuild/lib/index.js:266 and verify that the bundle is smaller and loads correctly?
@tido64 thanks for the quick response!
Confirm that adding "template-literal": true to the table in node_modules/@rnx-kit/metro-serializer-esbuild/lib/index.js:266 resolves issues with tagged string templates and styled-components.
But the problem is not primarily related to styled-components and tagged templates. It’s due to some combination of how esbuild and Hermes generate the code.
Here is an example with enum that gets transpiled into a combination like the one below, which produces the same problem as tagged templates::
var _TextType;
_TextType = _TextType || (_TextType = {})
You can check it here: pull - https://github.com/Augustach/esbuild-hermes-issue/pull/1/files branch - https://github.com/Augustach/esbuild-hermes-issue/tree/enums
Thanks for verifying the change. I've merged it since I don't think it relates to the new issue you brought up and it's good to have regardless.
I had a quick look at the enum case, and I can't say I can repro your output. This is what I see for Metro:
var TextType = /*#__PURE__*/function (TextType) {
TextType[TextType["Simple"] = 1] = "Simple";
return TextType;
}(TextType || {});
var Comp1 = function Comp1(_ref) {
var text = _ref.text;
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
children: text
});
};
var Comp2 = function Comp2(_ref2) {
var text = _ref2.text;
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
children: text
});
};
var Module0 = exports.Module0 = function Module0() {
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_$$_REQUIRE(_dependencyMap[4], "../../TouchableOpacity").TouchableOpacity, {
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(Comp1, {
text: TextType.Simple
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(Comp2, {
text: TextType.Simple
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(Comp3, {
text: TextType.Simple
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(Comp4, {
text: TextType.Simple
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(Comp5, {
text: TextType.Simple
})]
});
};
vs. the equivalent after going through esbuild:
var TextType = /* @__PURE__ */ function(TextType1001) {
TextType1001[TextType1001["Simple"] = 1] = "Simple";
return TextType1001;
}(TextType || {});
var Comp1 = function Comp12(_ref2) {
var text = _ref2.text;
return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(import_react_native2.Text, {
children: text
});
};
var Comp2 = function Comp22(_ref2) {
var text = _ref2.text;
return /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(import_react_native2.Text, {
children: text
});
};
var import_jsx_runtime22 = __toESM(require_jsx_runtime());
var Module0 = function Module02() {
return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(TouchableOpacity3, {
children: [/* @__PURE__ */ (0, import_jsx_runtime22.jsx)(Comp1, {
text: TextType.Simple
}), /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(Comp2, {
text: TextType.Simple
}), /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(Comp3, {
text: TextType.Simple
}), /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(Comp4, {
text: TextType.Simple
}), /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(Comp5, {
text: TextType.Simple
})]
});
};
enum {} is a TypeScript construct and is transformed by Babel before it gets passed to the serializer (where esbuild is plugged in). As far as I can tell, the output is almost identical, save for the names and transforming /*#__PURE__*/ to /* @__PURE__ */. How this affects the bundle size (making it ~140KB bigger than Metro) is something I don't fully understand yet.
How this affects the bundle size (making it ~140KB bigger than Metro) is something I don't fully understand yet.
Actually, I do know where some of this comes from. There is an overhead of injecting a function call into every module to ensure polyfills etc. are run before the main module:
https://github.com/microsoft/rnx-kit/blob/20f6de0fd27853f225abc9f73269308187a1afb7/packages/metro-serializer-esbuild/src/index.ts#L263-L274
Removing it shaves off ~100KB of the bundle (though the final bundle won't load properly). This is a technical limitation of the esbuild API not allowing arbitrary code to be inject at the global level (the equivalent to Metro's getModulesRunBeforeMainModule). And I'm not sure it's something we can easily fix.
Edit: There's an open issue on this: https://github.com/evanw/esbuild/issues/1557