This repo is an experiment to try and figure out how to best structure the React Router codebase in version 4.4 (and history version 4.8) so that we can get the benefits of tree shaking when users bundle their apps with webpack.
The main problem we are are trying to solve is that when people import
a piece of the router, they actually get more pieces than they need. For example, if someone imports BrowserRouter
in their React Router app, they shouldn't also get HashRouter
. Similarly, HashRouter
imports createHashHistory
, which is also not needed in the output bundle.
In the past we solved this by asking people to import only the pieces they need (i.e. import Router from 'react-router/BrowserRouter'
) but the promise of ES modules is that we should be able to statically analyze the code, figure out what pieces of these modules are not being used, and automatically remove them from the parse tree so they do not appear in the output.
The goal of this experiment is to import { BrowserRouter } from 'react-router'
and have no trace of HashRouter
or createHashHistory
in the output bundle. Ideally we would also have the smallest possible bundle size.
Each test case is self-contained in its own directory. The app.js
file imports BrowserRouter
from the dependencies which are kept in the packages
directory.
Each test has a build.js
script that can be used to build the whole test case and a test.js
script that can be used to test the output for traces of undesired artifacts.
Also, every dependency uses { "sideEffects": false }
in its package.json
to instruct webpack to assume that no modules in the library have side effects. This means that webpack will insert a #__PURE__
instruction with the iifes that it creates so that minifiers (like uglify) can assume they are pure.
In this test case I used Rollup to bundle all ES modules in the dependencies into a single module (history.js
and react-router.js
).
I ran webpack in development mode and used { optimization: { usedExports: true } }
so we could see comments in the output bundle about which exports were being used and which ones weren't. Toward the end of the output bundle we see:
/*!***********************************************!*\
!*** ./packages/react-router/react-router.js ***!
\***********************************************/
/*! exports provided: HashRouter, BrowserRouter, Route */
/*! exports used: BrowserRouter, Route */
webpack knows react-router
's HashRouter
export is not being used, but it does not remove it from the output bundle.
We would prefer to distribute a single file for each build, mainly because it's cleaner that way. Also, our builds are faster, and there is less code duplication in the individual modules for things like Babel helpers.
However, although webpack knows HashRouter
isn't being used, it still leaves it in the app's output bundle when we use this strategy. Let's try again.
In this test case I continued to use Rollup as before, but switched webpack into production mode, to see if this would be able to eliminate all traces of HashRouter
from the app's output bundle. I also use the __DEV__
flag to avoid adding static propTypes
declarations to our React components in production.
It works! The output bundle contains no traces of HashRouter
.
Note: the #__PURE__
marks in the react-router.js
package bundle. These allow those functions to be stripped out of the app's output bundle if they are not used. This is not technically tree-shaking, but rather dead code elimination performed by uglify when webpack is in production mode.
This is essentially the same as 1 but the dependencies use plain functions instead of ES class syntax. It was suggested by some that ES classes were responsible for us not being able to do tree-shaking properly. Turns out there is no difference.
This is essentially the same as 2 but the dependencies use plain functions instead of ES class syntax. However, unlike 2 webpack is not able to get rid of HashRouter
this time.
This is because the dependency bundle does not include #__PURE__
iifes when we write classes by hand.
This test case compiles dependencies using Babel directly into several files, so each package has an esm
directory with the build. webpack is run in development mode so we can inspect the output more easily.
In order to avoid getting duplicate copies of Babel's helpers in our dependencies when they are bundled with our app, we need to use @babel/plugin-external-helpers
and generate a babelHelpers.js
file with the helper functions we will need and import
that into the modules that need it. This is a little more work, but not much.
The tests still fail in this case (webpack hasn't eliminated any code), but we some familiar lines in the output bundle:
/*!********************************************!*\
!*** ./packages/react-router/esm/index.js ***!
\********************************************/
/*! exports provided: HashRouter, BrowserRouter, Route */
/*! exports used: BrowserRouter, Route */
webpack still knows that HashRouter
is unused, but it still leaves it in the output.
This test case is the same as 5, except this time webpack runs in production mode. In this mode, webpack uses uglify to eliminate dead code.
And this time ... it works! There are no traces of createHashHistory
or HashRouter
in the app's output bundle.
Note that we also don't have to use the __DEV__
flag to strip the static propTypes
in this case. This is an advantage over 2 when you must use statics for some reason.
If you ship a single bundle with Babel + Rollup and you want your library to be tree-shakeable, do NOT use static
properties on any of your classes (2).
If you must use static
s, distribute your library as separate files instead (and import
your own babelHelpers.js
file) and it will still be tree-shakeable (6).