evanw / esbuild

An extremely fast bundler for the web

Home Page:https://esbuild.github.io/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Support jsx automatic runtime

re-thc opened this issue · comments

React 17 will be adding jsx automatic runtime. It's now available in the latest release candidate.

More info here.

i.e. react/jsx-runtime and react/jsx-dev-runtime

Thanks!

The React 17 JSX transform seems more complicated and React-specific then the standard JSX transform, so I'm not sure it's something I'm going to include in esbuild's core. It should be very straightforward to support this using a plugin though. I'm still working toward plugin support but I'm getting close. You can subscribe to #111 for updates about plugin support.

React has an official blog post about this feature now.

There is also a detailed RFC describing some of the motivations for this change. I like this one:

Additionally, if we ever want to standardize more of JSX we need to start moving away from some of the more esoteric legacy behaviors of React.

I'd love to see e.g. Vue be able to "just work" with JSX without any weird CLI tools running additional transforms, which is currently the case.

None of this is high-urgency. These changes are still in-progress. But I think it is something to be considered down the road.

FYI the recently-released --inject feature can be used to automatically insert an import of React into each file. There is more detail in the release notes.

I noticed the new --inject feature, which looks pretty cool.

This solves some (but not all) of the "JSX automatic runtime", which is really part of an overall revision to how JSX itself is transpiled. For example:

Pass key separately from props

Currently, key is passed as part of props but we'll want to special case it in the future so we need to pass it as a separate argument.

jsx('div', props, key)

Always pass children as props

In createElement, children gets passed as var args. In the new transform, we'll always just add them to the props object - inline.

The reason we pass them as var args is to distinguish static children from dynamic ones in DEV. We can instead pass a boolean or use two different functions to distinguish them. My proposal is that we compile <div>{a}{b}</div> to jsxs('div', {children: [a, b]}) and <div>{a}</div> to jsx('div', {children:a}). The jsxs function indicates that the top array was created by React. The nice property of this strategy is that even if you don't have separate build steps for PROD and DEV, we can still issue the key warning and still pay no cost in PROD.

So the proposal requires changes to how JSX is transformed into function calls, which I assume requires changes to the esbuild core.

@chowey It's too bad that React is trying to change how they use JSX considering there are hundreds of frameworks/libraries that use "standard" JSX of method(tag?, attrs?, ...children). I think it's great that esbuild can support all of these frameworks and that it doesn't try to bend over for React. Adding any framework-specific changes to a bundler feels like an easy way to grow the codebase and complexity.

It's great that React has a Babel plugin though, and especially considering React is always in development and the RFC you quoted isn't settled, the best option is likely telling esbuild to preserve (i.e not touch) JSX and then tell Babel to convert all JSX found in all output chunks afterwards.

Agreed. This upgrade is entirely optional. From the React blog post:

Upgrading to the new transform is completely optional, but it has a few benefits:

  • With the new transform, you can use JSX without importing React.
  • Depending on your setup, its compiled output may slightly improve the bundle size.
  • It will enable future improvements that reduce the number of concepts you need to learn React.

This upgrade will not change the JSX syntax and is not required. The old JSX transform will keep working as usual, and there are no plans to remove the support for it.

To reiterate, support for the JSX automatic runtime is completely optional and will continue to be completely optional into the future.

Regarding changes to JSX, I would love for JSX to be simple and standardized, so that other non-React frameworks can use it too. So I don't mind a change to JSX provided it moves in that direction. I once tried to create a Vue app in JSX, without using Babel (which means no @vue/babel-preset-jsx). It was horrible. Vue's createElement behaves differently than React's createElement and requires all sorts of special handling. If JSX can converge on a standard that works for everyone, then that is a big plus.

Any progress on this? It's been a few months. It looks like a plugin system now exists (albeit experimentally); but it sounds like from above that a core change is required (so plugins may be irrelevant).

If you just want to use JSX without importing React, you can do this with esbuild today using the built-in inject feature. No changes to esbuild are required.

If you still want to use the slightly different JSX implementation that comes with React 17, it's trivial to do this with an esbuild plugin. It would look something like this:

const jsxPluginReact17 = {
  name: 'jsx-react-17',
  setup(build) {
    const fs = require('fs')
    const babel = require('@babel/core')
    const plugin = require('@babel/plugin-transform-react-jsx')
      .default({}, { runtime: 'automatic' })

    build.onLoad({ filter: /\.jsx$/ }, async (args) => {
      const jsx = await fs.promises.readFile(args.path, 'utf8')
      const result = babel.transformSync(jsx, { plugins: [plugin] })
      return { contents: result.code }
    })
  },
}

Cool, thanks!

commented

If you still want to use the slightly different JSX implementation that comes with React 17, it's trivial to do this with an esbuild plugin. It would look something like this:

const jsxPluginReact17 = {
  name: 'jsx-react-17',
  setup(build) {
    const fs = require('fs')
    const babel = require('@babel/core')
    const plugin = require('@babel/plugin-transform-react-jsx')
      .default({}, { runtime: 'automatic' })

    build.onLoad({ filter: /\.jsx$/ }, async (args) => {
      const jsx = await fs.promises.readFile(args.path, 'utf8')
      const result = babel.transformSync(jsx, { plugins: [plugin] })
      return { contents: result.code }
    })
  },
}

Doesn't that means it's using Babel instead of esbuild to transform jsx? Does it has any performance advantages than using Babel directly?

Doesn't that means it's using Babel instead of esbuild to transform jsx? Does it has any performance advantages than using Babel directly?

I presume this would take a performance hit for sure. I think it'll end up being a tradeoff in performance for a simpler migration path for babel-powered codebases.

The more plugins you use, the slower your build will get, especially if your plugin is written in JavaScript.

Based on the plugin api docs

I've been trying to replace ts-loader with esbuild-loader in my webpack configuration and I stumbled on this issue. I did basically the following:

  1. I had removed hundreds of import statements by switching to react's new jsx transform (yay!).
  2. I've tried to replace ts-loader with esbuild-loader because that's just so amazingly slow.
  3. The thing doesn't work because esbuild can't work with the new transform.

Now I think I have the following paths forward:

  1. esbuild-loader uses esbuild's transform API, which don't/can't support the "inject" option, so maybe I should add back all those import statements (no way).
  2. Maybe I should configure esbuild to use the babel transform, but introducing something as slow as babel into my build process would go against the whole goal here.
  3. Maybe I should try to patch esbuild to add support for this and maintain a fork of that (no intention to do that).
  4. Maybe I should just give up on adding more esbuild goodness into my build process, and wait until I can throw the entirety of webpack into the bin and switch to esbuild, if that's ever going to happen.

IMHO all the available options aren't great at all, especially if growing esbuild's userbase is a goal here.

I don't know if that's a strong enough argument for implementing the new transform into core, but I'd imagine there are a good number of people in a situation similar to mine that just can't use more of esbuild without unreasonable efforts, plus in theory the new transform should unlock some perf in the future, which react+esbuild users would want to tap into as well, plus even though there are maybe hundreds of tools using JSX maybe React users account for more than 75% of all the JSX users, like if it weren't for them I'm not sure we would even have JSX. Also TypeScript shipped with support for this, so supporting it in esbuild too would make it support more of TypeScript too.

Hopefully there will be a way for me to switch to esbuild with less compromises at some point, I'm so tired of these super slow tools.

@fabiospampinato Have you considered:

  1. Switch to esbuild directly instead of using webpack at all

In the same way you were calling babel slow, I'd argue using webpack at all is slow. Attaching esbuild as a loader seems like it is adding complexity. Many people are able to use esbuild plugins to support many webpack transforms (like LESS), and move away from webpack entirely.

Once you're using esbuild directly you can use the inject feature to keep those imports removed but still use the common/standard JSX format.

I have a feeling it'll be the fastest option too

@heyheyhello I have considered it, but there are too many issues with that presently:

  1. First of all some things I need the bundler to do for me esbuild can't currently do yet (chunks on dynamic imports), some plugins I need don't exist for esbuild (webpack-bundle-analyzer), and some things I'm using webpack for might be out of scope for esbuild (hmr).
  2. But also replacing that single loader in my configuration would provide most of the value with ~0 efforts, switching to another bundler entirely would take much more time, and may not pan out in the end. Plus while webpack is surely slow on its own most of the time is actually spent processing modules, if esbuild could take care of that that would provide most of the wins already.

@evanw apparently Vite works around this issue via supporting an extra "jsxInject" property, by the sound of it I guess it just prepends modules with that string. Would that be something that could be implemented into esbuild itself? It should work decently enough and be easy to maintain, and it may have other uses, perhaps if named differently.

Screen Shot 2021-04-14 at 19 10 29

@fabiospampinato I believe Vite's inject API is the same as the inject API already built in to esbuild, which Evan and I are describing. In the first paragraph of this comment here: #334 (comment)

Vite's inject API just places this snippet to the header of codes by simply concatenating string:

https://github.com/vitejs/vite/blob/a0d922e7d9790f998c246f8122bc339717b6088f/packages/vite/src/node/plugins/esbuild.ts#L125

And that may break something, see: vitejs/vite#2369

The dev-server of Vite uses transform API of esbuild so it can't use the inject option of esbuild (that is build API only option)

2. Maybe I should configure esbuild to use the babel transform, but introducing something as slow as babel into my build process would go against the whole goal here.

@fabiospampinato Babel isn't necessarily going to be too slow given that you'll need fewer plugins. Essentially you're configuring it to do as little work as possible and leave the rest of the work to esbuild.

At my work we came up with the following config to transform only the JSX auto runtime, and configured Babel to only run on .tsx files.

presets: [
  [
    '@babel/preset-react',
    {
      development: !isProduction,
      runtime: 'automatic',
    },
  ],
],
plugins: [
  // Allow Babel to parse TypeScript without transforming it
  ['@babel/plugin-syntax-typescript', { isTSX: true }],
],

We're using the targets option of esbuild for some browser-specific transforms that were previously done by @babel/preset-env.

We're using a similar approach to #334 (comment) but with @rollup/plugin-babel followed by rollup-plugin-esbuild on a fairly large component library. For a rough comparison, a build on my machine takes ~2.5s with Babel & esbuild versus ~1.5s with just esbuild; the latter fails at runtime of course. Before we introduced esbuild, we had Babel with preset-env and preset-typescript in addition to the above and we were seeing builds take around 1-2 minutes so obviously we can settle for an extra second or two.

Note that this is without type checking - for that we just use tsc with noEmit and incremental enabled.

So you might want to try this and see how it works for you. I guess for webpack you could have babel-loader followed by esbuild-loader. For type checking you could consider fork-ts-checker-webpack-plugin.

Unfortunately I can't share the repo as it's private but I might be able to work up a sample repo if I get time with some examples that combine Babel & esbuild for webpack, rollup and esbuild (with Babel via plugin API) along with some more detailed benchmarks.

@chowey It's too bad that React is trying to change how they use JSX considering there are hundreds of frameworks/libraries that use "standard" JSX of method(tag?, attrs?, ...children). I think it's great that esbuild can support all of these frameworks and that it doesn't try to bend over for React. Adding any framework-specific changes to a bundler feels like an easy way to grow the codebase and complexity.

It's great that React has a Babel plugin though, and especially considering React is always in development and the RFC you quoted isn't settled, the best option is likely telling esbuild to preserve (i.e not touch) JSX and then tell Babel to convert all JSX found in all output chunks afterwards.

I don't use React in many projects but I still want to use JSX. The new changes to JSX actually solve basically all my gripes I had with using the original JSX in non-react projects. I don't believe the new changes are more react-centric. If anything they're less react-centric. React could just go on their way as they always have but these new changes make it much more performant and easier to wrap compiled jsx functions to whatever vdom you're using.

One of the most annoying things about the old JSX was trying to render JSX statically. There was no way to tell the difference between text and static html. React team mentioned this in their post also. My solution was to wrap every static html string in an array then flatten all the arrays on the next iteration. It was very yucky. The new JSX has better ways of doing this.

Would you be willing to reconsider adding this feature now that TypeScript 4.11 is released and supports these options? I unfortunately need the transform API, so the plugin wouldn't work for me. I'd be willing to try and work on it if you could provide some direction for what needs to be done for this feature to be implemented.

@ctjlewis FYI, the JSX function and the React.creatElement do not have the same function signature so that will likely lead to some weird bugs.

The functions are different on how they handle “children” and “key” props.

Feel bad to pile on here, but it is quite annoying to have to do a shim, especially when Typescript is perfectly happy with my code as is without the react import. I know we don't want esbuild to be tied to any one framework, but it seems like it should be possible add an option for automatic jsx runtime injection with different presets such as for react.

I was ok with the proposed shim idea, but I really don't like how the react-shim is included in every file. Sometimes you don't want that.

commented

For anyone coming into this thread, this is achievable by adding a createElement import in the banner and setting it as the JSX factory.

await esbuild({
  ...shared,
  entryPoints: tsxFiles.filter((file) => !file.endsWith(".d.ts")),
  jsxFactory: "createElement",
  banner: {
    js: "import { createElement } from 'react';\n",
  },
});

Footnotes

.d.ts files

You have to exclude .d.ts files or it will throw. For some reason, forcing { loader: "tsx" } would also prevent the TSX from compiling correctly, so do not set that in your shared settings.

react/jsx-runtime

React's JSX runtime will not work as a JSX factory because esbuild assumes a createElement-like signature, so the config above is the only way to get valid runtime output.

For anyone coming into this thread, this is achievable by adding a createElement import in the banner and setting it as the JSX factory.

await esbuild({
  ...shared,
  entryPoints: tsxFiles.filter((file) => !file.endsWith(".d.ts")),
  jsxFactory: "createElement",
  banner: {
    js: "import { createElement } from 'react';\n",
  },
});

Footnotes

.d.ts files

You have to exclude .d.ts files or it will throw. For some reason, forcing { loader: "tsx" } would also prevent the TSX from compiling correctly, so do not set that in your shared settings.

react/jsx-runtime

React's JSX runtime will not work as a JSX factory because esbuild assumes a createElement-like signature, so the config above is the only way to get valid runtime output.

@ctjlewis This doesn't seem to work for me. I just get ReferenceError: createElement is not defined. This is my config (FYI using esbuild-jest for tests):

{
  sourcemap: true,
  jsxFactory: 'createElement',
  banner: {
    js: "import { createElement } from 'react';\n"
  }
}
commented

@alextompkins Does the emitted bundle contain the import statement we need? Why would it say it's undefined? I use this pattern in @tsmodule/tsmodule so I know it's stable.

Post a link to demo and I'll review it. What are your entryPoints? Can you post a pastebin of an emitted bundle or link me a demo to run and debug?

Make sure not to use { loader: "tsx" }.

@ctjlewis - your solution worked for me for libraries that consider react to be external, but not for applications - where I want react bundled in.

This slight modification of your solution helped me -

// build.js
build({
  jsxFactory: "createElement",
  pure: ["createElement"],
  inject: ['./react-shim.js'],
  // ... other options
})

// react-shim.js
window.createElement = require("react").createElement;

Not a great solution but it works ¯\(ツ)

cc: @alextompkins

@evanw What's your stance on this today a few years later? The newer JSX transform has been adopted in TypeScript and similar to babel they expose an importSource option to specify the package to import the runtime from. This works really well for us for Preact.

For anyone trying to get this to work for TypeScript the following plugin works, however it does have significant performance penalty. A project with 1000 tsx files takes 12s vs 2s. This is really a suboptimal solution when combined with watch: true because this is actually slower than Webpack.

const jsxSource = {
  name: 'jsx-source',
  setup(build) {
    const fs = require('fs');
    const babel = require('@babel/core');

    build.onLoad({ filter: /\.tsx$/ }, async args => {
      const jsx = await fs.promises.readFile(args.path, 'utf8');
      const result = babel.transformSync(jsx, {
        filename: args.path,
        presets: [
          '@babel/preset-typescript',
          [
            '@babel/preset-react',
            {
              development: true,
              runtime: 'automatic',
            },
          ],
        ],
        plugins: ['@babel/plugin-transform-react-jsx-source'],
      });
      return { contents: result.code };
    });
  },
};

Looks like using a shim is still the only concise way of doing this, especially when you use esbuild with es6.

For anyone using the command line, use --inject e.g.:

esbuild app/client.jsx --bundle --inject:app/react-shim.js --outfile=dist/client.js

My app/react-shim.js content:

export * as React from 'react'

@kjoedion This works automatically now using the --jsx=automatic option.