Skip to content
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

In some cases, the Hermes bundler produces a larger bundle with Esbuild than with Metro. #3361

Open
3 of 5 tasks
Augustach opened this issue Sep 16, 2024 · 4 comments
Open
3 of 5 tasks
Labels
bug Something isn't working

Comments

@Augustach
Copy link

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?

  • Android
  • 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

  1. Clone https://github.com/Augustach/esbuild-hermes-issue
  2. Run the script yarn copy-modules. This will generate 1000 copies of the ./src/template.tsx file.
  3. Run ESBUILD=true yarn build to create the esbuild bundle.
  4. Check the size of the ./output/index.android.bundle.hrs Hermes bundle.
  5. Run ESBUILD=true yarn build to create the metro bundle.
  6. Check the size of the ./output/index.android.bundle.hrs Hermes 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

  • I agree to follow this project's Code of Conduct
@Augustach Augustach added the bug Something isn't working label Sep 16, 2024
@Augustach Augustach changed the title In some cases, the Hermes bundler produces a larger bundle with Esbuild than with Metro.” In some cases, the Hermes bundler produces a larger bundle with Esbuild than with Metro. Sep 16, 2024
@tido64
Copy link
Member

tido64 commented Sep 16, 2024

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?

@Augustach
Copy link
Author

@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

@tido64
Copy link
Member

tido64 commented Sep 17, 2024

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.

@tido64
Copy link
Member

tido64 commented Sep 17, 2024

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:

inject: [
/**
* A require call is generated and prepended to _all_ modules for each
* injected file. This can increase the bundle size significantly if
* there are many polyfills and modules. For just four polyfills (e.g.
* `console.js`, `error-guard.js`, `Object.es7.js`, and
* `InitializeCore.js`), we've seen an increase of ~180 KB in a small
* to medium sized app. We can work around this issue by adding all
* the polyfills in a single file that we inject here.
*/
prelude,
],

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: evanw/esbuild#1557

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

2 participants