-
-
Notifications
You must be signed in to change notification settings - Fork 10.4k
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
Bundling packages into single files is preventing useful tree-shaking #6464
Comments
Can you install |
@pshrmn my test repo linked above already uses ( |
Oh, I glanced at the wrong |
AFAIK, only Rollup does built-in DCE for flat bundles. Webpack outsources that to a minifier (UglifyJS by default). Is this the size including minification? I would imagine a large chunk of that will disappear once something actually eliminates the dead code. |
@timdorr yes, DCE via minification is being applied. See the test repo, where webpack's The outsourcing you describe is exactly the problem: Uglify is not so good at DCE, especially of inter-dependent functions defined in a single module. webpack generally gets much better results when it can prune entire modules based on static imports/exports, which is why the |
@billyjanitsch Yep, something blocks treeshaking here. Will take a look. |
I wonder if Terser or babel-minify would be able to handle this better. I'm fairly certain babel-minify can do it. Not that it's a solution, mind you. It leaves those using the default config out in the cold :/ |
@timdorr It's actually can be handled by better treeshaking. Rollup solves a lot but we still stuck on transpiled classes. React hooks will solve the problem with treeshakability. |
I don't think that this issue is directly related to those link changes. I disabled module concatenation to see the unused exports, and the // react-router-dom exports
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return BrowserRouter; });
/* unused harmony export HashRouter */
/* unused harmony export Link */
/* unused harmony export NavLink */
// react-router exports
/* unused harmony export MemoryRouter */
/* unused harmony export Prompt */
/* unused harmony export Redirect */
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return Route; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "b", function() { return Router; });
/* unused harmony export StaticRouter */
/* unused harmony export Switch */
/* unused harmony export generatePath */
/* unused harmony export matchPath */
/* unused harmony export withRouter */
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "c", function() { return context; });
// history exports
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { return createBrowserHistory; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "b", function() { return createHashHistory; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "d", function() { return createMemoryHistory; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "c", function() { return createLocation; });
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "f", function() { return locationsAreEqual; });
/* unused harmony export parsePath */
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "e", function() { return createPath; }); |
@pshrmn I solved the problem of react-router-dom only. There is a work to be done in react-router. |
Thank you for bringing this to our attention, @billyjanitsch. I was planning on verifying that tree-shaking was working as I'd hoped before shipping 4.4 final. When we do ship 4.4, we need to have a recommended strategy for people to be able to eliminate all pieces of the router they aren't using. If that means using Rollup, I'm fine with that, though it'd be nice if we could support webpack 4 too. |
Glad to hear this! 😊 And thanks for considering.
That would be unfortunate, IMO. Rollup is relatively common for bundling libs, but webpack is overwhelmingly used to bundle apps, whereas react-router is almost exclusively consumed by apps rather than libs. Could you explain why the solution of not bundling the ESM builds into a single file isn't really being considered? As I see it, it would allow both webpack and Rollup to fully tree-shake the library without any special config, and it wouldn't incur any bundle size increase or initialization cost. The upsides of bundling into a single file (mentioned by @TrySound in #6465 (comment)) seem inaccurate or comparatively small:
Only to a trivial degree -- the time difference between bundling 1 vs ~10 files is negligible.
I'm not suggesting that react-router should support deep imports. Only the top-level package should be imported from (
Modern bundlers (including webpack 4) inline ESM imports/exports at bundle time without any glue code, so the number of import statements has no effect on the resulting bundle. It just provides more surface area for bundlers to do module-level tree-shaking, which is a good thing.
I see why this is a nice guarantee to have, but as long as the readme makes clear that users shouldn't access library internals (no Do these upsides really outweigh not being able to support tree-shaking in webpack and/or having to rely on brittle optimization patterns (e.g. remembering never to use default props or do other static property assignment)? |
I meant these statements in the same project. They are inconsistent. import {BrowserRouter} from 'react-router-dom'
import Link from 'react-router-dom/Link'
Less import statements to write for users. What do you mean by "module-level tree-shaking"? Path imports? It's not treeshaking. It's user execution. I know it as material-ui user.
Importing from library paths is not treeshaking. Treeshaking is machine analyses - those brittle optimization patterns. The main idea of those patterns is simple: "use only functions". |
@TrySound I think there's been a misunderstanding. 🙂 I am not suggesting path imports as a solution. We both agree that path imports are very bad. Let me make an example repo to show you what I mean by module-level tree-shaking. |
@TrySound I made an example repo to demonstrate. Please compare the multiple-files branch to the single-file branch. Both branches contain an identical index.js which has The difference is that the single-file branch's The multiple-files branch's Comparing the build output of the multiple-files branch to the single-files branch, you'll notice that only the multiple-files branch was able to tree-shake I hope this helps you understand what I mean by module-level tree-shaking. It does not affect how users consume or import from the package, and I am not recommending path imports. |
I tried converting this to a basic rollup project and that failed to properly tree shake the unused history exports, too. rollup configconst resolve = require("rollup-plugin-node-resolve");
const commonjs = require("rollup-plugin-commonjs");
const replace = require("rollup-plugin-replace");
module.exports = {
input: "src/index.js",
output: {
file: "dist/bundle.js",
format: "iife",
globals: {
react: "React",
"react-dom": "ReactDOM"
}
},
external: ["react", "react-dom"],
plugins: [
resolve(),
commonjs({
include: /node_modules/,
namedExports: {
"react-is": ["isValidElementType"]
}
}),
replace({
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV)
})
]
}; My best understanding of this problem is this Webpack issue. That issue also implies that module concatenation/minimization should fix it (which doesn't appear to be the case here) and that there is a plugin to do "deep scope analysis" to mark the I really wish that there were visual tree shaking tools/plugins. |
@pshrmn You need to add uglify or terser to eliminate transpiled classes with pure annotation. Rollup does not handle them. Transpiled classes is evil. Everything will work perfectly with hooks. I will try to optimize react-router better. |
FWIW this is the biggest blocker to releasing 4.4 for me right now. If we don't have an easy solution for people to keep their bundle size down, we should probably revert and tell people to use |
@mjackson Please don't do this, if possible. This prevents the ESM build from being used (
What's wrong with the solution I proposed in the OP, detailed in #6464 (comment)? Just stop rolling the ESM build into a single file. |
Yeah, seems like terser/uglify suck in dead code elimination. Transpiled classes suck. We are in the bad state until we will fully migrate to hooks. |
However it's probably not so bad. I didn't dig into final bundle but I'm almost sure that all left code will be always consumed by users. So there's no reason to achieve 100% treeshakability right now. |
My apologies for not addressing your comments propertly until now, @billyjanitsch. I think building ESM to multiple files could definitely be a workable solution for now, at least until webpack is able to tree-shake single file builds better. Question for you: does the multiple file solution work for people who are consuming the CommonJS build? e.g. using a bundler that doesn't understand ES modules, like an earlier version of webpack? Or do those people still need to use relative path imports to simulate the benefits of tree-shaking? |
Don't hurry with rewriting everything back please. Working on solution. |
Has this stalled out? Seems activity has dropped out here.. |
@jadbox |
@TrySound Do you mean we can use tree-shaking plugins like lodash-webpack-plugin for react-router, too? |
@Stupidism No, lodash-webpack-plugin cherry pick imports (import { map } from 'lodash' -> import map from 'lodash/map'). Treeshaking is different. It is a kind of minification process which for example checks that function is not used and does not include it in output bundle. Treeshaking works out of the box with webpack and rollup. |
I believe all our code is tree-shakable for now, except for I'm going to push a new beta release today (beta 7) that should be fully tree-shakable. Please let me know if you see any issues, @billyjanitsch. Closing for now. |
Version
react-router-dom@4.4.0-beta.6
Test Case
https://github.com/billyjanitsch/react-router-tree-shaking
Steps to reproduce
Clone the repo above, run
npm run build
, then observe the output indist/main.js
.Expected Behavior
The biggest gains to be had from tree-shaking in react-router/history are probably removing whichever of browser/hash/memory histories are not used by the app. For example, if only
BrowserRouter
is imported from react-router-dom, one would expect the hash and memory routers, along with their associated histories, to be tree-shaken.Actual Behavior
Neither of the unused histories are tree-shaken. You can verify this by searching for
hashType
in the build output, which only appears increateHashHistory
and therefore should have been tree-shaken from the bundle. As a result, the output bundle is quite large: importing onlyBrowserRouter
yields a 33.2 kB bundle, whereas importing the entire library yields a barely-larger 37.9 kB bundle.I believe this happens because webpack is generally not good at tree-shaking unused parts of a module -- it's much better at tree-shaking entire unused modules (particularly when
sideEffects: false
has been set). For example, ifcreateHashHistory
lived in a separate module, webpack would know that the module import was unused and prune the entire module.What was the motivation for bundling history, react-router, and react-router-dom into single files on npm? I understand why it's more efficient to do this for CJS modules, but for ESM, it seems strictly better to leave the bundling to the bundler, since modern bundlers are capable of inlining ESM without adding glue code, with the advantage that they can be smarter about pruning entire modules.
(Another theory is that this re-export is the problem.)
/cc @TrySound @mjackson
The text was updated successfully, but these errors were encountered: