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

Support named exports in Node.js native ES2015 modules implementation #38

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

dantman
Copy link

@dantman dantman commented Mar 31, 2018

View formatted RFC

I would appreciate it if we kept the merits of .mjs and how Node.js chose to implement ES2015 modules out of the discussion here. We should just accept the fact that ES2015 modules are getting a native implementation in Node.js, this is what we have to work with, and focus on fixing React so that it works with the implementation we are getting.


- ESM code **must** be inside of a `.mjs` file
- ESM code **must** use ES2015 `import` and `export` and cannot use `require`
- ESM code **must** declare all of its named exports in the `.mjs` file or they must come from another `.mjs` file exported using `export * from './somefile'`. (You likely could use `*` on a CommonJS module, but it would only export `default`)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export * from explicitly ignores reexporting a default export; so it'd export nothing.

To completely reexport all exports of a module you need both export * from and export { default } from for the same module.

export const cloneElement = React.cloneElement;
export const createFactory = React.createFactory;
export const isValidElement = React.isValidElement;
export const version = React.version;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These can even be drastically simplified to:

export const {
  Children,
  createRef,
  Component,
  // ... etc
} = React

- Alternatively, React.js could exclusively export named exports and an alternative to `index.js` could both `export * from './React';` and `import * as React from 'react'; export default React;` to get the `React` object as a free side effect.
- The build process will need to output `./esm/react.{production.min,development}.mjs` bundles in addition to the normal `./cjs/` bundles. I expect these will just be the Rollup bundle already being generated but without the module transpilation step.
- A `index.mjs` will sit at the root beside `index.js`, it will act the same as `index.js` but will export the `./esm` bundles using ES2015 exports instead of the `./cjs` bundles.
- Unresolved: It is not actually possible to do the `if (process.env.NODE_ENV === 'production') {` conditional that `index.js` does in ESM, ES modules require that import is at the top of the file and does not permit conditional importing.
Copy link

@Jessidhia Jessidhia Jun 15, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible but only with dynamic import(), and that also requires that anything that must go through that code path be made async.

It'd still work without going with async imports if the module body itself has no side-effects; even if imported, it'll just do nothing, and can be removed by dead code elimination.

Copy link
Member

@gaearon gaearon Jun 15, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the most natural solution is to do conditional re-exports.
Like

export const createElement = (process.env.NODE_ENV === 'production') ? createElementProd : createElementDev;

in the entry point. I don't think the ecosystem is ready for this yet though, given how fragile tree shaking is.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Alternatively we could decide that we are not going to support named imports of React from ESM.

This will not be a breaking change. Use of .mjs and ESM is opt-in functionality. All packages using babel or a bundler to access node will keep working with the non-spec compliant handling of named exports from CommonJS modules.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless their build system automatically chooses mjs when it's available. In this case it would be breaking.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.mjs files aren't supposed to be imported from require in .js files (including all current babel ES6 import usage in .js, which really uses require behaviour under the hood).

In fact the esm page explicitly states that "require('./foo.mjs') is unsupported because "ES Modules have differing resolution and timing".

Any build-system automatically choosing .mjs when available would be violating ESM.


This will not be a breaking change. Use of .mjs and ESM is opt-in functionality. All packages using babel or a bundler to access node will keep working with the non-spec compliant handling of named exports from CommonJS modules.

The only requirement will be that if someone decides to switch to `.mjs` and opt-in to using ESM instead of transpiling/bundling, they will be required to refactor their entire codebase to use `React.Component` instead of named exports.
Copy link
Member

@gaearon gaearon Jun 15, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also that 90% of tutorials / examples will be wrong. Seems like a pretty bad situation to be in. On the other hand TS already enforces using named imports only. I dunno.

It seems like it's reasonable to provide a default export but slowly encourage people to using named or namespace imports instead? And maybe eventually drop the default one.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already migrated in a bunch of react packages. I think the best thing we can do is a migration to import * React from 'react' in docs and blaming import React, { Component } from 'react' which doesn't fit to any way. I could start this migration if you will write clean post in this thread.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use default exports with TypeScript. But it is true that using default import for React from TypeScript does not work.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migrating to import * as React from 'react' is the wrong migration to do; it only "works" if you are using TypeScript's old commonjs emit. You should be using --esModuleInterop and using import React from 'react' instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't typescript handle both cases with interop like everybody does? Rollup, webpack, babel handle this without problems.

Copy link

@Jessidhia Jessidhia Jun 16, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It "can", and you need to give --esModuleInterop. However, my point is that if you use --module es2015 or --module esnext, the resulting output (if renamed to .mjs as it needs to be) will be broken and crash at runtime on node unless you use the default import.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be fixed in perspective so we could start migrating to import * as React from 'react'. Or do you think we stuck with default export forever?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is what this RFC is meant to address.

@chyzwar
Copy link

chyzwar commented Jul 22, 2018

It is unknown when a node will support ESM and what will be the form. https://github.com/nodejs/modules Modules team in node did not publish timeline and previous PRs in the node were postponed. This is already the third attempt of ESM in the node. IMO focus should be on browser ESM implementation that works now.

@gaearon
Copy link
Member

gaearon commented Oct 19, 2018

We also need to consider what to do with react-test-renderer/shallow entry point which currently exports a function in CommonJS.

@TrySound
Copy link
Contributor

We can do the same or just change exports to

import { TestRenderer, Shallow } from 'react-test-renderer';

@sebmarkbage
Copy link
Collaborator

I think the general thinking here is that we want to do something like this but it'd be a breaking change and only worth doing along with other breaking changes...?

@dantman
Copy link
Author

dantman commented Mar 2, 2019

I think the general thinking here is that we want to do something like this but it'd be a breaking change and only worth doing along with other breaking changes...?

The Node.js ESM implementation has been pushed back to October and has changed a bit in implementation. So the RFC is kind of on hold.

Theoretically this change should not be breaking since it only affects people writing .mjs files. Additionally Node has even changed the behaviour in a way that adding an index.mjs won't break current users using .mjs. That said there's a chance WebPack could completely screw up that plan. So perhaps it should be bundled with breaking changes anyways.

It's tangentially related. But I wonder if we should also revisit TypeScript. It's really annoying having to use import * as React from 'react'; instead of import React, {useState} from 'react';. Which could easily be solved with essentially a const React = {...}; modules.export = {...React, default: React};.

@TrySound
Copy link
Contributor

TrySound commented Mar 2, 2019

@dantman Default export is not the right way to import from react. React is a namespace. Logically it should not be default export. Default export also requires hacks like babel plugin to transform

React.createElement()
// to
const { createElement } = React
createElement()

When bundler may do this for free and even better will not generate const { createElement } = React in every file.

Treeshaking will not be significant with react but it still can be considered as a feature.

@Jessidhia
Copy link

React should be a namespace. It currently is not.

@dantman
Copy link
Author

dantman commented Mar 2, 2019

Default export is not the right way to import from react. React is a namespace.

You're going to have to rally for JSX to be changed then. Because the default is React.createElement and React must be defined in context.

Treeshaking will not be significant with react but it still can be considered as a feature.

Treeshaking will be completely nonexistent. React is bundled into a single file and uses an object export.

@TrySound
Copy link
Contributor

TrySound commented Mar 2, 2019

You're going to have to rally for JSX to be changed then. Because the default is React.createElement and React must be defined in context.

Not at all. import * as React from 'react'; is universal solution. Default export can be provided as a temporary fix.

Treeshaking will be completely nonexistent. React is bundled into a single file and uses an object export.

I'm talking about the time when react will have esm and named exports.

@dantman
Copy link
Author

dantman commented Mar 2, 2019

I'm talking about the time when react will have esm and named exports.

React's single file bundles were an intentional choice, even if React ever gets ESM it will likely still use single file bundles.

@TrySound
Copy link
Contributor

TrySound commented Mar 2, 2019

@dantman Wait, I didn't say anything about react bundles. I like them.

My point was that react should not have default export because it increases user bundle a lot or requires babel plugins to solve the problem and still with trade offs.

@gaearon
Copy link
Member

gaearon commented Feb 10, 2020

How does this proposal hold up against the latest shipped Node implementation?

@dantman
Copy link
Author

dantman commented Feb 11, 2020

How does this proposal hold up against the latest shipped Node implementation?

There seem to be some changes to the ESM handling. The general idea of the RFC seems to still be valid, but about 10% needs a few tweaks for the ESM changes.

Scanning the new https://nodejs.org/api/esm.html docs here are the things that stand out:

  • File extensions are now mandatory, so the wrapper would need to explicitly use .mjs and the suggestion of ./index for the main field would be removed.
  • .js files in a package can also be ESM if you include "type": "module" in the package.json but we're not changing the default (unless you want to switch to ESM code for React 17 with optionally a CommonJS .cjs fallback for compatibility with older node) so we'd do the opposite and explicitly declare "type": "commonjs".
  • Instead of placing an index.js and index.mjs next to each other, package.json has a new exports field. It supports conditional exports to direct require and import to different files, so this is how you'd setup that instead of an extension-less main.
    • It's not relevant here, but if exports gains wider adoption it could mean the disappearance of needing to include lib/dist/es/module folders in imports.
  • The proposal I made of using an .mjs wrapper file to export named identifiers appears to be the recommended approach for packages vulnerable to the dual package hazard (where one dep doing import and another doing require would result in two instances of React being imported). So the general idea of the RFC seems to be validated by this.
  • The ESM docs propose a browser conditional export variant (like the variants for import/require/node). If tooling adopts this (whatever tooling people would be using to generate browser native type=module packages) then this could possibly be a solution in the future for supporting browser native module packages without dropping the compatibility with CommonJS-only versions of node. (Basically export a full module version of React and only target it to browsers. This avoids the dual package hazard because Node still uses the wrapper not vulnerable and a browser that only supports ESM won't import the CJS version).

@gaearon Is there any desire to implement this anytime soon? If so I can revisit this now and update the RFC. Otherwise I can revisit it the next time there is new news on Node's ESM implementation (changes from experimental to stable, finishes the loader API, other toolkits adopt the ESM behaviours, or a number of React users indicate they wish to use ESM).

@jaydenseric
Copy link

How does this proposal hold up against the latest shipped Node implementation?

The import specifiers in ESM .mjs files must contain the full filenames, including extensions (except when using a bare specifier to import from main package exports).

E.g. when importing ESM into ESM:

- import { Foo } from './Foo'
+ import { Foo } from './Foo.mjs'

E.g. when importing CJS into ESM:

- import Foo from './Foo'
+ import Foo from './Foo.js'

@gaearon
Copy link
Member

gaearon commented Feb 11, 2020

@gaearon Is there any desire to implement this anytime soon?

"Soon" is relative but it's always helpful to have an up-to-date plan in case it seems like the right moment to start the work. In particular, I'm worry about how to be compatible with Node, bundlers, Flow/TS, and everything else.


Here are the requirements that the ESM implementation in Node.js place on us and it's behaviours we can take advantage of:

- ESM code **must** be inside of a `.mjs` file
Copy link

@Andarist Andarist Jul 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not technically correct - you can use .js if you put within a directory containing package.json with {"type": "module"} and you can even use .js extension for both CJS and ESM entries, u "just" need to extra package.json files. For example, Rollup is using this "trick": https://unpkg.com/browse/rollup@2.23.0/dist/es/

This could have not been true at the time of writing this RFC though.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could have not been true at the time of writing this RFC though.

Correct. At the time of writing the RFC this is how the Node.js ESM implementation worked. It was only available in .mjs files and the type in package.json did not exist. The implementation has changed significantly and type was noted in my list of differences.

For React's purpose though, .mjs would still be used for the ES module wrapper unless the project were to make a large breaking change switching entirely to a ES modules codebase.

@stefee
Copy link

stefee commented Jul 25, 2020

This RFC seems to be getting some attention at the moment. I’d like to direct any folks who are interested in it to the Node.js documentation, and in particular the “Dual CommonJS/ES module packages” section which provides a more complete and up-to-date picture: https://nodejs.org/dist/latest-v14.x/docs/api/packages.html#packages_dual_commonjs_es_module_packages

There is also an ongoing discussion in facebook/react#11503.

@gaearon
Copy link
Member

gaearon commented Aug 18, 2021

Our latest thinking was that we'd drop default exports altogether. Named only.

@jonkoops
Copy link

Our latest thinking was that we'd drop default exports altogether. Named only.

Perhaps this should be deprecated starting React 19?

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

Successfully merging this pull request may close these issues.