Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why is my React component library not tree-shakable?

I have a React component library that I’m bundling with rollup. Then I’m consuming that library in an app setup with create-react-app which uses Webpack under the hood. I expect Webpack to tree-shake the component library. After building the app bundle and analyzing it I see that the library has either not been tree-shaken at all or that tree-shaking didn’t work on the library because it is not tree-shakable in the first place. Why is tree-shaking not working? What am I doing wrong?

rollup.config.js (bundler configuration of the React component library)

import babel from 'rollup-plugin-babel'
import commonjs from 'rollup-plugin-commonjs'
import autoExternal from 'rollup-plugin-auto-external'
import resolve from 'rollup-plugin-node-resolve'
import reactSvg from 'rollup-plugin-react-svg'
import url from 'rollup-plugin-url'
import string from 'rollup-plugin-string'
import pureanno from 'rollup-plugin-pure-annotation'

import pkg from './package.json'
const { getSVGOConfig } = require('./scripts/getSVGOConfig')

const MAX_INLINE_FILE_SIZE_KB = 100

export default {
  input: 'src/index.js',
  output: [
    {
      file: pkg.module,
      format: 'es',
    },
  ],
  plugins: [
    autoExternal(),
    babel({
      babelrc: false,
      exclude: 'node_modules/**',
      plugins: [
        'external-helpers',
        'babel-plugin-transform-react-jsx',
        'babel-plugin-transform-class-properties',
        'babel-plugin-transform-object-rest-spread',
        'transform-react-remove-prop-types',
        [
          'babel-plugin-root-import',
          {
            'rootPathSuffix': 'src',
          },
        ],
        'babel-plugin-styled-components',
        'transform-decorators-legacy',
        [
          'ramda',
          {
            'useES': true,
          },
        ],
      ],
    }),
    resolve(),
    commonjs(),
    reactSvg({
      svgo: getSVGOConfig(),
    }),
    url({
      limit: MAX_INLINE_FILE_SIZE_KB * 1024,
      include: ['**/*.woff', '**/*.woff2'],
    }),
    string({
      include: '**/*.css',
    }),
    pureanno({
      includes: ['**/*.js'],
    }),
  ],
  watch: {
    chokidar: false,
  },
}

src/index.js of the React component library

export { default as Theme } from './Theme'
export { default as Badge } from './components/Badge'
...

App.js (the app consuming the library)

import React from 'react';
import { Theme, Badge } from 'my-react-component-library'

function App() {
  return (
    <Theme>
      <Badge>Hello</Badge>
    </Theme>
  )
}

export default App

package.json of the React component library (relevant parts)

{
  "name": "my-react-component-library",
  "version": "1.1.1",
  "main": "dist/index.js",
  "module": "dist/index.es.js",
  "scripts": {
    ...
    "build": "rollup -c",
  },
  "dependencies": {
    ...
  },
  "peerDependencies": {
    "react": "^15.0.0 || ^16.0.0",
    "react-dom": "^15.0.0 || ^16.0.0"
  },
  "devDependencies": {
    ...
  },
  "sideEffects": false
}

package.json of the app consuming the library (relevant parts)

{
  "name": "my-app",
  "version": "0.1.0",
  "dependencies": {
    "my-react-component-library": "^1.1.1",
    "react": "^16.12.0",
    "react-dom": "^16.12.0"
  },
  "scripts": {
    ...
    "analyze": "source-map-explorer build/static/js/*chunk*.js build/static/js/*chunk*.js.map",
    "build": "react-scripts build",
    "serve": "serve -s build"
  },
  "devDependencies": {
    ...
    "serve": "^11.3.0",
    "source-map-explorer": "^2.2.2"
  }
}

index.es.js (the bundled react component library)

https://gist.github.com/borisdiakur/ae376738955f15fb5079b5acb2ac83ad

like image 518
borisdiakur Avatar asked Jan 30 '20 10:01

borisdiakur


2 Answers

You took a step in the right direction. Tree shaking works on file boundaries, it drops files that are not used inside your import path and has no side effects.

Your first example couldn't utilize tree shaking because your whole package was bundled inside a single file.

Your second example bundles into multiple files but your main bundle (with the index.js input) is still a huge file that bundles everything together instead of leaving the imports inside it alone. (This is just an assumption based on your posted build process, please check your index.js bundle to verify this).

You have to either:

  • define the dependencies of index.js as externals. Try adding the whole /src directory as external.

    OR

  • Use babel instead of rollup for your build process to leave exports and imports intact.

If you have questions like this just check the material UI build process and final build folder (npm i @material-ui/core and look into the node_modules). That's a nice inspiration for the solution.

like image 135
Bertalan Miklos Avatar answered Sep 21 '22 06:09

Bertalan Miklos


I found one possible solution to my problem. It has nothing to do with tree-shaking though. I’m simply splitting the library into several independent chunks by making use of a rather new feature of rollup (I had to upgrade a bunch of dependencies in order for it to work) and providing an object, mapping names to entry points, to the input property of the rollup configuration. It looks like this:

input: {
    index: 'src/index.js',
    theme: 'src/Theme',
    badge: 'src/components/Badge',
    contentCard: 'src/components/ContentCard',
    card: 'src/elements/Card',
    icon: 'src/elements/Icon',
    ...

Here is rollup’s documentation for it: https://rollupjs.org/guide/en/#input

The output is set to a directory:

output: [
  {
    dir: 'dist/es',
    format: 'es',
  },
],

Then I declare the entry point in my package.json as follows:

"module": "dist/es/index.js",

In my test app I import the components as if nothing changed:

import React from 'react';
import { Theme, Badge } from 'my-react-component-library'

That seems to work so far, though it’s again not tree-shaking and I would still like to know how to make my component library tree-shakable.

UPDATE:

Turns out tree shaking worked all the time! Here is what was “wrong” with the library:

  1. The Icon component imported all icons so that all svg files ended up in the bundle as soon as you used at least one icon or a component that uses an icon.
  2. The Theme component inlined a font as a base-64 string into the bundle.

I resolved the first issue by dynamically importing each icon when needed and the second issue by reducing the MAX_INLINE_FILE_SIZE_KB parameter for rollup-plugin-url in order to split out the font and have it loaded as an asset.

So, here is my advice for anybody who like me starts believing that tree-shaking doesn’t work, just because the bundle is ridiculously large: Double-check your bundle analysis report (i.e. using source-map-explorer), look for the big guys and double-check your imports.

like image 23
borisdiakur Avatar answered Sep 22 '22 06:09

borisdiakur