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

Parcel2 v. Webpack - parcel bundle size is significantly larger #4565

Closed
astegmaier opened this issue May 4, 2020 · 16 comments · Fixed by #4861
Closed

Parcel2 v. Webpack - parcel bundle size is significantly larger #4565

astegmaier opened this issue May 4, 2020 · 16 comments · Fixed by #4861

Comments

@astegmaier
Copy link
Contributor

astegmaier commented May 4, 2020

🐛 bug report

I've started trying to use Parcel2 in a real-life situation, and I'm noticed that the parcel bundle size (1.1MB) is significantly larger than webpack's (666 KiB) for the same app. (It's also significantly slower - see #4566).

Like all real-life situations, it's complicated, so I tried to create a simplified repro environment in the fluentui-button-babel branch of this repo. It's a simple react app with a single Button component from the @fluentui/react-northstar library. This library is alpha-quality, and there's known issues about bundle size that the team is trying to address. However, webpack still does a significantly better job than parcel right now. The main driver seems to be that this optimization that the library authors made is picked up by webpack but not by parcel.

🎛 Configuration (.babelrc, package.json, cli command)

Webpack (full config here):

  • Using babel-loader with this config:
presets: [
   "@babel/preset-env",
   "@babel/preset-react",
   "@babel/preset-typescript",
]

Parcel configuration:

  • No .parcelrc file, which means that the default babel transformer is being used.
  • No custom babel configuration.
  • Ran parcel build src/index.html

src/index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Sample App</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <script src="./index.tsx"></script>
  </body>
</html>

src/index.tsx:

import React from "react";
import ReactDOM from "react-dom";
import App from "./components/App";

ReactDOM.render(<App />, document.getElementById("root"));

src/components/App.ts:

import React from "react";
import { Provider, themes, Button } from "@fluentui/react-northstar";

const App = () => (
  <Provider theme={themes.teams}>
    <Button content="Hello from FluentUI" />
  </Provider>
);

export default App;

🤔 Expected Behavior

Parcel and webpack build size should be roughly the same (or maybe Parcel should be better 😄).

😯 Current Behavior

Here's the bundle size comparison:

Scenario Bundle Size
Build with parcel (default babel config with scope-hoisting and minification) 1.11 MB
Build with webpack (babel-loader with scope-hoisting and minification) 660 KiB
Build with webpack (ts-loader with module: es6 with scope-hoisting and minification) 660 KiB
Build with webpack (ts-loader with module: commonjs with scope-hoisting and minification) 1.21 MB

💁 Possible Solution

By using source-map-explorer and a custom node script, I generated a visualization of the stuff that is in the parcel bundle, but not the webpack bundle:

image

You can see that the main driver of the increased size are icons from the @fluentui/react-icons-northstar. The fluentui team recently made an optimization to allow webpack to tree-shake these icons, and it seems that it works with webpack, but not with parcel.

I'm not sure why this is happening - most likely something at the scope-hoisting phase that doesn't like the way this library was written. Even if there's something the library authors could do to improve this, it seems like a good goal to do our best to make sure that libraries that are optimized for webpack remain optimized with parcel.

🔦 Context

Trying to build a real-world app that uses @fluentui/react-northstar and parcel2.

💻 Code Sample

See the fluentui-button-babel branch of this repo.

🌍 Your Environment

Software Version(s)
Parcel 2.0.0-nightly.248
Node 12.16.3
Yarn 1.22.4
Operating System OSX 10.15.4
@mischnic
Copy link
Member

mischnic commented May 4, 2020

One reason that this library is rather hard to tree shake is that the module exports are assigned to and used inside the declaration, and the non-pure function call in the exports (which makes it even theoretically impossible to shake):

function Box() {
	console.log(Box.displayName);
	return "This is a Box!";
}

Box.displayName = "Box";

export default withSafeTypeForAs(Box);

(This withSafeTypeForAs is a noop and is only a workaround to make tsc happy. They really should remove that or add a PURE comment for terser.)

This means the either deferring mechanism needs to handle this:

export { default as Button } from "./Button";
export * from "./Button";
export { default as Box } from "./Box";
export * from "./Box";

However, the export * from prevents that (at the moment).

And the wildcard export also prevents Parcel from excluding the asset in the packager (because it determined that Box isn't actually used): #4220, #4387


We need to rework the import/(re)export wildcard handling.

@astegmaier
Copy link
Contributor Author

Thanks for taking a look! I think you're right to focus on re-export handling. Here's two more data points I found that might be helpful:

  1. If you change the way you import from @fluentui/react-northstar to this...

    import Box from "@fluentui/react-northstar/dist/es/components/Box/Box";
    import Provider from "@fluentui/react-northstar/dist/es/components/Provider/Provider";
    import teamsTheme from "@fluentui/react-northstar/dist/es/themes/teams";
    

    ...the package size shrinks to 631KiB (compared to 622 KiB for Webpack). That tells me that the main problem relates to way parcel tree-shakes the re-exporting that's happening in @fluentui/react-northstar/src/index.ts and child index files.

  2. Even if you leave the imports as-is (i.e. import { Provider, themes, Box } from "@fluentui/react-northstar"), if you comment out line 301 from @fluentui/react-northstar/src/index.ts...

     //export * from '@fluentui/react-icons-northstar';
    

    ...the bundle size shrinks to 777KiB (compared to 660 KiB in webpack). So most (but not all) of the size increase in Parcel is due to us not tree-shaking that statement as successfully as webpack.

@astegmaier
Copy link
Contributor Author

astegmaier commented May 5, 2020

I also wanted to clarify that there are at least two separate things going wrong in this scenario:

  1. We're not tree-shaking @fluentui/react-northstar components - i.e. including a single component in your app results in many (most?) other components in the package getting included too. The library authors are working on this right now, but I don't think we should focus on it for now because it affects webpack too, and it's probably an issue with the library.
  2. In parcel, including a single component in your app ends up including all icons from @fluentui/react-icons-northstar too. This doesn't affect webpack - somehow it's able to tree-shake the icons. For the goal of "keeping libraries optimized for webpack also optimized for parcel", I think we should focus here.

@astegmaier
Copy link
Contributor Author

astegmaier commented May 6, 2020

I tested some of the @mischnic's hypotheses about what is producing issue 2, and came up with simplified repro. You can see it in the reexport-with-wrappers branch of this repo.

I created a fake npm package (src/node_modules/fake-package) with 3 characteristics. In my tests, it appears that all three are necessary in order to see the problem:

  1. An index file that re-exports everything another file:

    src/node_modules/fake-package/index.js

    export * from "./messages";
  2. The file re-exported by index.js contains statements that re-export the default exports of some other files:

    src/node_modules/fake-package/messages.js

    export { default as message1 } from "./message1";
    export { default as message2 } from "./message2";
  3. The default exports are wrapped within a function before being exported:

    src/node_modules/fake-package/message1.js

    import { wrapper } from "./wrapper";
    const message1 = "Hello World!";
    export default wrapper(message1);

    src/node_modules/fake-package/message2.js

    import { wrapper } from "./wrapper";
    const message2 = "Goodbye World!";
    export default wrapper(message2);

    src/node_modules/fake-package/wrapper.js

    export function wrapper(string) {
      console.log("I just wrapped a string: ", string);
      return string;
    }

Then, in the main app, we import an use only one of the exports (message1):

src/index.ts

import { message1 } from "fake-package";

ready(() => {
  const root = document.getElementById("root");
  if (root) {
    root.textContent = message1;
  }
});

function ready(fn: () => void) {
  if (document.readyState != "loading") {
    fn();
  } else {
    document.addEventListener("DOMContentLoaded", fn);
  }
}

When the bundle is built by webpack with tree-shaking, but without minification (yarn build:webpack:raw), you can see that the output was successfully tree-shaked - message2 / "Goodbye World!" is not present.

When the same bundle is built by parcel2 (parcel build src/index.ts --no-minify), message2 / "Goodbye World!" is still in the bundle even though it is unused by the app. The same string ("Goodbye World!") is present in the minified build.

Here is the (unminified) bundle out put from parcel:

(function () {
  // ASSET: /Users/Andrew/Projects/parcel-webpack-comparer/src/node_modules/fake-package/messages.js
  // ASSET: /Users/Andrew/Projects/parcel-webpack-comparer/src/node_modules/fake-package/message1.js
  // ASSET: /Users/Andrew/Projects/parcel-webpack-comparer/src/node_modules/fake-package/wrapper.js
  function $baa56c52e94c37cfa6e4f688d9d4f65$export$wrapper(string) {
    console.log("I just wrapped a string: ", string);
    return string;
  }

  const $ddbb96709a4990d332eedf0727c5879$var$message1 = "Hello World!";
  var $ddbb96709a4990d332eedf0727c5879$export$default = $baa56c52e94c37cfa6e4f688d9d4f65$export$wrapper(
    $ddbb96709a4990d332eedf0727c5879$var$message1
  );
  const $d3a229d82d83fefab2a3266a3adfcbac$var$message2 = "Goodbye World!";
  var $d3a229d82d83fefab2a3266a3adfcbac$export$default = $baa56c52e94c37cfa6e4f688d9d4f65$export$wrapper(
    $d3a229d82d83fefab2a3266a3adfcbac$var$message2
  );
  $b04660a404e967a338647ceb28cef6e$var$ready(function () {
    var root = document.getElementById("root");

    if (root) {
      root.textContent = $ddbb96709a4990d332eedf0727c5879$export$default;
    }
  });

  function $b04660a404e967a338647ceb28cef6e$var$ready(fn) {
    if (document.readyState != "loading") {
      fn();
    } else {
      document.addEventListener("DOMContentLoaded", fn);
    }
  }
})();
//# sourceMappingURL=parcel-webpack-comparer.86a5affc.js.map

@astegmaier
Copy link
Contributor Author

astegmaier commented May 6, 2020

I confirmed that it is possible to fix the problem by adding /*#__PURE__*/ annotations to the default exports, like this:

src/node_modules/fake-package/message1.js

import { wrapper } from "./wrapper";
const message1 = "Hello World!";
export default /*#__PURE__*/ wrapper(message1);

src/node_modules/fake-package/message2.js

import { wrapper } from "./wrapper";
const message2 = "Goodbye World!";
export default /*#__PURE__*/ wrapper(message2);

Although a conscientious library maintainer might take the time to test and do this, it seems more likely that many won't (e.g. @fluentui/react-northstar was only testing with webpack which is hard to criticize at the moment, given webpack's current popularity). I suspect consumers of libraries will have a better experience if we try to emulate webpack behavior here.

Another thought: although the repro steps seem pretty specific, I suspect that this might be relatively easy to hit. It's not uncommon to use higher-order-components in react to compose functionality and export the result as default (i.e. step 3 above). Steps 1 and 2 might be less common, but for larger libraries with lots of components, it's not an unreasonable way to organize things.

One reason that this library is rather hard to tree shake is that the module exports are assigned to and used inside the declaration, and the non-pure function call in the exports (which makes it even theoretically impossible to shake):

I take the point about tree-shaking this sort of export being incorrect in some cases (e.g. when the wrapping function has side-effects). So I guess there's a tradeoff between matching webpack behavior and being technically correct. I wonder what motivated webpack's decision? I can dig around on github, but does anyone know who the contacts are?

@kzc
Copy link

kzc commented May 7, 2020

The pure annotation on the exported function call should not be required. The module in question specifies "sideEffects": false. parcel@next does not appear to respect this hint as rollup and webpack do.

Using the fake-package in the unannotated example above with the entry point:

$ cat main2.js 
import {message1, message2} from "fake-package";
console.log(message1);

compare:

$ echo '{}' > node_modules/fake-package/package.json 
$ rollup main2.js -p node-resolve --silent | node
I just wrapped a string:  Hello World!
I just wrapped a string:  Goodbye World!
Hello World!

to:

$ echo '{ "sideEffects": false }' > node_modules/fake-package/package.json
$ rollup main2.js -p node-resolve --silent | node
I just wrapped a string:  Hello World!
Hello World!

@mischnic
Copy link
Member

mischnic commented May 7, 2020

The pure annotation on the exported function call should not be required.

Yes. I really meant: Parcel failed to optimize this and the call doesn't make it easier for Terser either

@kzc
Copy link

kzc commented May 7, 2020

Rollup handles this sideEffects-free scenario itself, but you might investigate whether webpack emits pure annotations for exported calls to the minifier in a production build.

@mischnic
Copy link
Member

mischnic commented May 7, 2020

Rollup handles this sideEffects-free scenario itself

In general, Parcel handles it as well (#4565 (comment)), it's just that we currently bailout with wildcard exports.
So with export * from "./messages"; it doesn't get removed, but export {message1, message2 from "./messages"; does work as expected.

(Thank you for magically appearing in minification-related issues BTW)

@kzc
Copy link

kzc commented May 7, 2020

Sorry for the noise about this known issue. I saw the "theoretically impossible to shake" comment and didn't realize you already had a strategy to deal with it.

(Thank you for magically appearing in minification-related issues BTW)

:-)
I make the rounds at every bundler project occasionally to see what's state of the art.
Side note: esbuild has made some real progress lately. It'll be interesting to watch.

@mischnic
Copy link
Member

mischnic commented May 7, 2020

theoretically impossible to shake

I mean AST-level shaking, not bundler level (sideEffects) shaking 😄

Side note: esbuild has made some real progress lately. It'll be interesting to watch.

Indeed, their performance is very impressive

@mischnic
Copy link
Member

mischnic commented May 9, 2020

Before I forget again: this is part of the problem:

// TODO (from PR #4385) - maybe comply to this once we have better symbol information?
//assert(!called, 'side effect called');

@mischnic
Copy link
Member

(I just found out that @material-ui/core suffers from exactly the same problem with the export * )

@mischnic
Copy link
Member

mischnic commented Jun 17, 2020

I have implemented the fix and the bundle size is 632kb compared to Webpack's 635kb, will open a PR after more testing.

(But 600kb is still massive for a single Button)

mattrossman added a commit to mattrossman/hmd-link that referenced this issue Jul 26, 2020
This was motivated by parcel's inability to properly tree shake the
Material UI library, as well as its half-baked bundle analysis solutions
in v2.

parcel-bundler/parcel#4565
mattrossman added a commit to mattrossman/hmd-link that referenced this issue Jul 26, 2020
This was motivated by parcel's inability to properly tree shake the
Material UI library, as well as its half-baked bundle analysis solutions
in v2.

parcel-bundler/parcel#4565
@astegmaier
Copy link
Contributor Author

astegmaier commented Apr 2, 2021

FYI for anyone re-visiting this issue - recent updates to parcel and @fluentui/react-northstar combined have made a huge difference in bundle size. New results are:

  • parcel: 544 KB
  • webpack: 496 KB

...which is much more reasonable (although could be better - part of the problem is that the fluentui theme imports definitions for every possible component, even if you're just using one). See the fluentui-button-babel branch of this repo to try for yourself.

@mischnic
Copy link
Member

mischnic commented Apr 2, 2021

Hopefully soon, this will go down to 500kb and also be a lot faster to build 😉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
3 participants