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

Formalize top-level ES exports #11503

Open
gaearon opened this issue Nov 9, 2017 · 116 comments
Open

Formalize top-level ES exports #11503

gaearon opened this issue Nov 9, 2017 · 116 comments

Comments

@gaearon
Copy link
Member

@gaearon gaearon commented Nov 9, 2017

Currently we only ship CommonJS versions of all packages. However we might want to ship them as ESM in the future (#10021).

We can't quite easily do this because we haven't really decided on what top-level ES exports would look like from each package. For example, does react have a bunch of named exports, but also a default export called React? Should we encourage people to import * for better tree shaking? What about react-test-renderer/shallow that currently exports a class (and thus would start failing in Node were it converted to be a default export)?

@Andarist
Copy link
Contributor

@Andarist Andarist commented Nov 9, 2017

Imho import * is a way to go, Im not opposed to having a default export too, but it shouldnt be used to reexport other stuff like in this example:

export const Component = ...
export default React
React.Component = Component

@gaearon
Copy link
Member Author

@gaearon gaearon commented Nov 9, 2017

but it shouldnt be used to reexport other stuff like in this example:

Is there a technical reason why? (Aside from having two ways to do the same thing.)

My impression is that people who would import * (and not use the default) wouldn't have problems tree shaking since default would stay unused. But maybe I overestimate Rollup etc.

@Andarist
Copy link
Contributor

@Andarist Andarist commented Nov 9, 2017

That questions can be probably best answered by @lukastaegert. Ain't sure if something has changed since #10021 (comment)

Also Rollup is not the only tree shaker out there, and while webpack's tree-shaking algorithm is worse than the one in rollup, it's usage is probably way higher than rollup's (both tools do excellent jobs ofc, I don't want to offend anyone, just stating facts) and if we can (as the community) help both tools at once we should do so whenever we can.

@jquense
Copy link
Contributor

@jquense jquense commented Nov 9, 2017

is tree-shaking going to do anything in React's case, given that everything is preprocessed into a single flat bundle? I wonder what the primary import style is for React, personally i tend to treat it like a default export e.g. React.Component, React.Children but occasionally do the named thing with cloneElement

@lukastaegert
Copy link

@lukastaegert lukastaegert commented Nov 10, 2017

As @gaearon already stated elsewhere, size improvements in case of react are expected to be minimal. Nevertheless, there ARE advantages:

  • React.Children might probably be removed in some cases (so I heard 😉)
  • React itself can be hoisted into the top scope by module bundlers that support this. This could again remove quite a few bytes and might also grant an oh-so-slight performance improvement. The main improvement would lie in the fact that there does not need to be another variable that references React.Component for every module but just one that is shared everywhere (this is how rollup usually does it). Also, though this is just me guessing, this might reduce the chance of webpack's ModuleConcatenationPlugin bailing out
  • Static analysis for react is easier not only for module bundlers but also for e.g. IDEs and other tools. Many such tools already do a reasonable job at this for CJS modules but in the end, there is a lot of guessing involved on their side. With ES6 modules, analysis is a no-brainer.

As for the kind of exports, of course only named export really provide the benefit of easy tree-shaking (unless you use GCC which might be able to do a little more in its aggressive move and maybe the latest rollup if you are really lucky). The question if you provide a default export as well is more difficult to decide:

  • PRO: Painless migration for existing ES6 code bases (e.g. what @jquense describes)
  • CON: Since everything is attached to a common object, once this object is included, all its keys are included at once which again defeats any attempts at tree-shaking. Even GCC might have a hard time here.

As a two-version migration strategy, you might add a default export in the next version for compatibility purposes which is declared deprecated (it might even display a warning via a getter etc.) and then remove it in a later version.

@gaearon
Copy link
Member Author

@gaearon gaearon commented Nov 13, 2017

This is also an interesting case: #11526. While monkeypatching for testing is a bit shady, we'll want to be conscious about breaking this (or having a workaround for it).

@Rich-Harris
Copy link
Contributor

@Rich-Harris Rich-Harris commented Nov 23, 2017

Came here via this Twitter conversation. For me, there's a clear correct answer to this question: React and ReactDOM should only export named exports. They're not objects that contain state, or that other libraries can mutate or attach properties to (#11526 notwithstanding) — the only reason they exist is as a place to 'put' Component, createElement and so on. In other words, namespaces, which should be imported as such.

(It also makes life easier for bundlers, but that's neither here nor there.)

Of course, that does present a breaking change for people currently using a default import and transpiling. @lukastaegert probably has the right idea here, using accessors to print deprecation warnings. These could be removed in version 17, perhaps?

I don't have a ready-made suggestion for #11526 though. Perhaps shipping ESM would have wait for v17 for that reason anyway, in which case there'd be no need to worry about deprecation warnings.

@gaearon
Copy link
Member Author

@gaearon gaearon commented Nov 23, 2017

People have really come to like

import React, { Component } from 'react'

so convincing them to give it up might be difficult.

I guess this is not too bad, even if a bit odd:

import * as React from 'react';
import { Component } from 'react';

To clarify, we need React to be in scope (in this case, as a namespace) because JSX transpiles to React.createElement(). We could break JSX and say it depends on global jsx() function instead. Then imports would look like:

import {jsx, Component} from 'react';

which is maybe okay but a huge change. This would also mean React UMD builds now need to set window.jsx too.

Why am I suggesting jsx instead of createElement? Well, createElement is already overloaded (document.createElement) and while it's okay with React. qualifier, without it claiming it on the global is just too much. Tbh I’m not super excited about either of these options, and think this would probably be the best middle ground:

import * as React from 'react';
import { Component } from 'react';

and keep JSX transpiling to React.createElement by default.

@Rich-Harris
Copy link
Contributor

@Rich-Harris Rich-Harris commented Nov 23, 2017

Confession: I always found it slightly odd that you have to explicitly import React in order to use JSX, even though you're not actually using that identifier anywhere. Perhaps in future, transpilers could insert import * as React from 'react' (configurable for the sake of Preact etc) on encountering JSX, if it doesn't already exist? That way you'd only need to do this...

import { Component } from 'react';

...and the namespace import would be taken care of automatically.

@gaearon
Copy link
Member Author

@gaearon gaearon commented Nov 23, 2017

In a distant future, maybe. For now we need to make sure transpilers work with other module systems (CommonJS or globals). Making this configurable is also a hurdle, and further splits the community.

@Andarist
Copy link
Contributor

@Andarist Andarist commented Nov 23, 2017

What @Rich-Harris suggested (inserting a specific import when jsx is used) is easily done by transpilers plugin. The community would have to upgrade their babel-plugin-transform-react-jsx and that's it. And of course even existing setups would still work if only one adds import * as React from 'react'; to the file.

Of course we need to consider other module systems, but it doesn't seem like a hard problem to solve. Are there any specific gotchas in mind?

@gaearon
Copy link
Member Author

@gaearon gaearon commented Nov 23, 2017

Of course we need to consider other module systems, but it doesn't seem like a hard problem to solve. Are there any specific gotchas in mind?

I don’t know, what is your specific suggestion as to how to handle it? Would what the default be for Babel JSX plugin?

@jamiewinder
Copy link

@jamiewinder jamiewinder commented Nov 23, 2017

People have really come to like

import React, { Component } from 'react'

What people? Come forth so that I may mock thee.

@gaearon
Copy link
Member Author

@gaearon gaearon commented Nov 23, 2017

I did that a lot 🙂 Pretty sure I've seen this in other places too.

@Andarist
Copy link
Contributor

@Andarist Andarist commented Nov 23, 2017

Default is at the moment React.createElement and it would pretty much stay the same. The only problem is that it assumes a global now (or already available in the scope).

I think as es modules are basically the standard way (although not yet adopted by all) of doing modules, it is reasonable to assume majority is (or should) use it. Vast majority already uses various build step tools to create their bundles - which is even more true in this discussion because we are talking about transpiling jsx syntax. Changing the default behaviour of the jsx plugin to auto insertion of React.createElement into the scope is imho reasonable thing to do. We are at the perfect time for this change with babel@7 coming soon (-ish). With recent addition of babel-helper-module-imports it is also easier than ever to insert the right type of the import (es/cjs) to the file.

Having this configurable to bail out to today's behaviour (assuming present in scope) seems really like a minor change in configuration needed for a minority of users and an improvement (sure, not a big one - but still) for majority.

@kzc
Copy link

@kzc kzc commented Dec 3, 2017

Should we encourage people to import * for better tree shaking?

Thanks to @alexlamsl uglify-es has eliminated the export default penalty in common scenarios:

$ cat mod.js 
export default {
	foo: 1,
	bar: 2,
	square: (x) => x * x,
	cube: (x) => x * x * x,
};
$ cat main.js 
import mod from './mod.js'
console.log(mod.foo, mod.cube(mod.bar));
$ rollup main.js -f es --silent | tee bundle.js
var mod = {
	foo: 1,
	bar: 2,
	square: (x) => x * x,
	cube: (x) => x * x * x,
};

console.log(mod.foo, mod.cube(mod.bar));
$ uglifyjs -V
uglify-es 3.2.1
$ cat bundle.js | uglifyjs --toplevel -bc
var mod_foo = 1, mod_bar = 2, mod_cube = x => x * x * x;

console.log(mod_foo, mod_cube(mod_bar));
$ cat bundle.js | uglifyjs --toplevel -mc passes=3
console.log(1,8);

@Andarist
Copy link
Contributor

@Andarist Andarist commented Dec 3, 2017

wow, that's great new 👏 is uglify-es considered to be stable now? I recall you mentioning few months back that it isn't there quite yet, but I can remember that incorrectly, so ain't sure.

Anyway - that's all and nice in a rollup world, but considering that React is bundled mostly in apps and those use mostly webpack which does not do scope hoisting by default, I'd still say that exporting an object as default should be avoided to aid other tools than uglisy-es+rollup in their efforts to produce smaller bundle sizes. Also for me it is semantically better to avoid this - what libs actually do in such cases is providing a namespace and it is better represented when using import * as Namespace from 'namespace'

@kzc
Copy link

@kzc kzc commented Dec 3, 2017

is uglify-es considered to be stable now?

As stable as anything else in the JS ecosystem. Over 500K downloads per week.

that's all and nice in a rollup world, but considering that React is bundled mostly in apps and those use mostly webpack which does not do scope hoisting by default

Anyway, it's an option. Webpack defaults are not ideal anyway - you have to use ModuleConcatenationPlugin as you know.

@lukastaegert
Copy link

@lukastaegert lukastaegert commented Dec 4, 2017

Adding a few cents here:

  • I totally agree with @Rich-Harris that semantically, named exports are the right choice
  • I really do not like either import React from 'react' or import * as React from 'react' just to be able to use JSX syntax. In my eyes, this design is clearly violating the Interface Segregation Principle in that it forces users to import all of React just to be able to use the createElement part (though admittedly with a namespace export, a bundler like Rollup will strip out the unneeded exports again)

So if we are at a point where we might make breaking-change decisions, I would advise to change this so that JSX depends on a single (global or imported) function. I would have called it createJSXElement(), which in my opinion describes it even better than createElement() and no longer needs the React context to make sense. But in a world where every byte counts, jsx() is probably ok, too.

This would also at last decouple JSX from React in a way such that other libraries can choose to support JSX by using the same transformation and supplying a different jsx function. Of course you have a lot of responsibility here guiding countless established applications through such a transformation but from an architectural point of view, this is where I think React and JSX should be heading. Using Babel to do the heavy lifting of such a transformation sounds like a great idea to me!

@Andarist
Copy link
Contributor

@Andarist Andarist commented Dec 4, 2017

Personally I do not see much gain in migrating to jsx helper as the default IMHO for the babel plugin should be importing it from the react package, so the name of the actual helper doesn't really matter - the rest is just matter of having it configurable.

@NMinhNguyen
Copy link
Contributor

@NMinhNguyen NMinhNguyen commented Dec 12, 2017

This is probably slightly tangential to the main discussion, but I'm curious how well ES modules work with checking process.env.NODE_ENV to conditionally export dev/prod bundles? For example,

if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}

I may be missing something obvious here, but I'm struggling to see how to translate this pattern into ES modules?

@milesj
Copy link
Contributor

@milesj milesj commented Dec 12, 2017

@NMinhNguyen Conditional exports aren't possible with ES modules.

@Andarist
Copy link
Contributor

@Andarist Andarist commented Dec 12, 2017

process.env.NODE_ENV checks can be at more granular (code) level though, ready to be replaced by the bundler with appropriate values.

@NMinhNguyen
Copy link
Contributor

@NMinhNguyen NMinhNguyen commented Dec 12, 2017

@Andarist @milesj Thanks for confirming my suspicion :)

process.env.NODE_ENV checks can be at more granular (code) level though, ready to be replaced by the bundler with appropriate values.

From the React 16 blog post I thought that the process.env.NODE_ENV checks were pulled out to the very top on purpose (as opposed to them being more granular, which is what they are in the source, if I'm not mistaken), to help performance in Node.js?

Better server-side rendering

React 16 includes a completely rewritten server renderer. It's really fast. It supports streaming, so you can start sending bytes to the client faster. And thanks to a new packaging strategy that compiles away process.env checks (Believe it or not, reading process.env in Node is really slow!), you no longer need to bundle React to get good server-rendering performance.

Like, I'm not sure how one could use the module field in package.json and differentiate between dev/prod for ESM while keeping ES bundles flat and not affecting Node.js perf

@Andarist
Copy link
Contributor

@Andarist Andarist commented Dec 12, 2017

Like, I'm not sure how one could use the module field in package.json and differentiate between dev/prod for ESM while keeping ES bundles flat and not affecting Node.js perf

This for sure is a drawback, because there is no standard way at the moment for doing this. OTOH it's just a matter of tooling, it is possible (and it's rather easy) to compile this in build steps of your application even today. Ofc it would be easier if package could expose dev/prod builds and the resolver would just know which one to pick, but maybe that's just a matter of pushing this idea to tooling authors.

@Andarist
Copy link
Contributor

@Andarist Andarist commented Nov 10, 2020

@nstepien providing a full exports map as you have shown in your previous post is not an option from what I believe. What node implements regarding cjs interop and stuff doesn't really play well with the existing ecosystem. Dual package hazard is real - especially for packages like Reac that requires a single copy of them.

An exports map with commonjs only files could potentially be added without breaking anything but would also have to be done extra carefully and with appropriate e2e tests for this (given how complex things are to get right)

@ljharb
Copy link
Contributor

@ljharb ljharb commented Nov 10, 2020

@Andarist it works fine, and is no different in react’s case, which has always had that hazard and the ecosystem solves it by making react a peer dep everywhere. An “exports” map can work just fine here, as long as the ESM files and CJS files share the same state - which can be achieved by writing simple ESM wrappers.

@Andarist
Copy link
Contributor

@Andarist Andarist commented Nov 10, 2020

If all dependencies, transitive or not, of React are under the control of React (which in this case they are) and ESM entry points of all of them are just reexporting CJS content then ye - maybe that is achievable in this particular case.

There is still the whole drama of what the actual shape of ESM entry should be though (named, default, both):

  • named-only: not really backward-compatible because a lot of the code out there is using import React from 'react', which is also the only way to actually import React in node right now when using ESM
  • default-only: not really backward-compatible because a lot of the code our there is using import * as React from 'react', this has often been promoted by type-checkers and other tools
  • both: the only way to make it fully backward-compatible, so it can work with all current loading styles and when mixing ESM & CJS modules across the dependency tree

I constantly forget about the possibility of ESM wrappers as they feel like a cheat but also because this technique only works if you control all of your dependencies and can't really be used as a universal strategy that would "just work" 😢

@ljharb
Copy link
Contributor

@ljharb ljharb commented Nov 11, 2020

I'm afraid the only universal strategy to provide both is, in fact, having all your actual code in CJS, and writing ESM wrappers around it to provide named exports. Code that is not stateful nor relies on identity can be natively written in both, but that's a subset, a caveat.

@nstepien
Copy link

@nstepien nstepien commented Nov 11, 2020

jsx-runtime isn't stateful right? Should be safe to ship both esm/cjs with no wrapper for it.

Should I log a separate issue for importing react/jsx-runtime in esm node?

@sokra
Copy link

@sokra sokra commented Nov 11, 2020

For frontend packages an extra challenge joines the dual state hazard: let call it the double bundle hazard.

When exporting ESM and CJS versions and the package is used via require and import, both versions would be bundled and double the effective bundle size for the package.

@nstepien
Copy link

@nstepien nstepien commented Nov 11, 2020

@sokra is that possible in practice? For example with rollup I would assume that since cjs modules are transformed into esm modules, that it would then prefer importing the esm modules whenever available.

@Andarist
Copy link
Contributor

@Andarist Andarist commented Nov 11, 2020

Its possible in practice for bundlers following node semantics - such as webpack 5. Its important to match semantics because otherwise u would potentially experience different results when running in node and when running a bundles code.

However, webpack 5 handles a special "module" condition that is used to deduplicate esm/cjs (the ones comming from exports map).

If we consider @ljharb’s proposal this isnt really relevant though because he proposes that esm file could be just a few lines of wrapper code that would just reexport the cjs file.

@ljharb
Copy link
Contributor

@ljharb ljharb commented Nov 11, 2020

With a thin ESM wrapper, there is zero additional hazard, with or without bundlers - only the normal one that peer deps avoid about dupes in the graph.

@juliuskovac
Copy link

@juliuskovac juliuskovac commented Jan 5, 2021

With all the respect, wrapper doesn't sound like cheating, it is cheating. Tree shaking and other tools to analyze code which know only about ES6 modules semantics won't fully work with it ever. If there will be major release of React, I think it will be more beneficial for community to go with correct ES6 modules and named-only exports, that's what versioning is about to do breaking changes if they are really necessary. If there is problem with tooling (to bundle two versions or other issue) it should be probably fixed on tooling side.

@ljharb
Copy link
Contributor

@ljharb ljharb commented Jan 5, 2021

@juliuskovac tree shaking works equally well with CJS, it's just not enabled by default in the popular bundlers, so they could already handle things without any ESM whatsoever if it was important to do so. The problem that needs fixing on the tooling side is "treeshaking often only works with ESM by default".

@lukastaegert
Copy link

@lukastaegert lukastaegert commented Jan 5, 2021

I assure you, it is not a question of "defaults". It is not impossible, but at least for Rollup, there is still some engineering effort necessary to safely track usage of object properties as would be required for CommonJS tree-shaking. And beyond tree-shaking, scope-hoisting is another optimization you forego without ES modules. In short it means you have a lot more property accesses with the incurred runtime penalty in a CommonJS setting. Rollup is actively trying to reduce these, but it requires libraries to be sufficiently "well-behaved". In short, it is quite hard to produce similar quality output from CommonJS modules as opposed to ES modules.

@ljharb
Copy link
Contributor

@ljharb ljharb commented Jan 5, 2021

Since dynamic imports exist, well-behaved libraries are required anyways.

@hronro
Copy link

@hronro hronro commented Jun 10, 2021

The plan for React 18 is finally announced, but I'm a little disappointed that there is no mention of ES module support at all.
ES module support is a feature request for a long time, and even this issue has been opened for about 4 years, I really hope the new major release (React 18) could have the feature implemented.

@bvaughn
Copy link
Collaborator

@bvaughn bvaughn commented Jun 10, 2021

One of our main goals for the 18.0 release is to remove as many upgrade obstacles as possible so that people can migrate and began taking advantage of the new features and fixes. ES modules would be a breaking change (so they would compete with this goal). They are still important to us though, and we hope to make the change sometime in the near future, just not as part of 18.0.

@wereHamster
Copy link

@wereHamster wereHamster commented Jun 11, 2021

There is currently much confusion about whether to import React from or import * as React from. Both work, currently, but that's only accidental due to how ESM imports of CommonJS modules are treated by bundlers.

I'd really appreciate an official stance by the React developers as to which form will be supported once React starts providing native ESM, so that we can start educating people now, update examples, blog posts, provide linter rules, emit bundler warnings etc now, rather than wait with it until the ESM support finally ships…

@milesj
Copy link
Contributor

@milesj milesj commented Jun 11, 2021

Pretty sure the path forward is named imports only, so import { useState } from 'react';. The default import is no longer required if you are using the new automatic JSX transform.

At work, we completely removed the default import and used the new transformer without any issues. It's much much cleaner.

@bvaughn
Copy link
Collaborator

@bvaughn bvaughn commented Jun 11, 2021

@milesj is correct. The "React" import is no longer required for JSX usage, thanks to the efforts of @lunaruan last year (via #16626 and a few other related commits)

@hronro
Copy link

@hronro hronro commented Nov 10, 2021

I'm a little bit confused about the roadmap now. If ES module will not come with v18.0, will it come with v18.x or v19.0? (or ... even later?)

I'm not sure if React.js follows semantic versioning. If it does, it's actually OK to introduce a breaking change in a major release.

If React.js doesn't follow semantic versioning and finally supports ES module in v18.x, it's still weird that React.js does not introduce breaking changes in a major version but introduces them in a minor version.

If React.js finally supports ES module in v19.0, OK, maybe we have to wait another 4 years for the ES module support.

@thientran1707
Copy link

@thientran1707 thientran1707 commented Apr 23, 2022

may I know if we have any status for the ESM module support from React?

@wereHamster
Copy link

@wereHamster wereHamster commented Jun 28, 2022

If #18102 is to be believed, then the react team has decided on ESM exports… namely on using named exports instead of a default export. So… can this be closed?

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

No branches or pull requests