Skip to content

Commit

Permalink
fix #334: support automatic JSX runtime (#2349)
Browse files Browse the repository at this point in the history
  • Loading branch information
jgoz committed Jul 28, 2022
1 parent 7a268da commit 0137224
Show file tree
Hide file tree
Showing 23 changed files with 1,136 additions and 94 deletions.
55 changes: 55 additions & 0 deletions CHANGELOG.md
@@ -1,5 +1,60 @@
# Changelog

## Unreleased

* Add support for React 17's `automatic` JSX transform ([#334](https://github.com/evanw/esbuild/issues/334), [#718](https://github.com/evanw/esbuild/issues/718), [#1172](https://github.com/evanw/esbuild/issues/1172), [#2318](https://github.com/evanw/esbuild/issues/2318), [#334](https://github.com/evanw/esbuild/pull/2349))

This adds support for the [new "automatic" JSX runtime from React 17+](https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html) to esbuild for both the build and transform APIs.

**New CLI flags and API options:**
- `--jsx`, `jsx` — Set this to `"automatic"` to opt in to this new transform
- `--jsx-dev`, `jsxDev` — Toggles development mode for the automatic runtime
- `--jsx-import-source`, `jsxImportSource` — Overrides the root import for runtime functions (default `"react"`)

**New JSX pragma comments:**
- `@jsxRuntime` — Sets the runtime (`automatic` or `classic`)
- `@jsxImportSource` — Sets the import source (only valid with automatic runtime)

The existing `@jsxFragment` and `@jsxFactory` pragma comments are only valid with "classic" runtime.

**TSConfig resolving:**
Along with accepting the new options directly via CLI or API, option inference from `tsconfig.json` compiler options was also implemented:

- `"jsx": "preserve"` or `"jsx": "react-native"` → Same as `--jsx=preserve` in esbuild
- `"jsx": "react"` → Same as `--jsx=transform` in esbuild (which is the default behavior)
- `"jsx": "react-jsx"` → Same as `--jsx=automatic` in esbuild
- `"jsx": "react-jsxdev"` → Same as `--jsx=automatic --jsx-dev` in esbuild

It also reads the value of `"jsxImportSource"` from `tsconfig.json` if specified.

For `react-jsx` it's important to note that it doesn't implicitly disable `--jsx-dev`. This is to support the case where a user sets `"react-jsx"` in their `tsconfig.json` but then toggles development mode directly in esbuild.

**esbuild vs Babel vs TS vs...**

There are a few differences between the various technologies that implement automatic JSX runtimes. The JSX transform in esbuild follows a mix of Babel's and TypeScript's behavior:

- When an element has `__source` or `__self` props:
- Babel: Print an error about a deprecated transform plugin
- TypeScript: Allow the props
- swc: Hard crash
- **esbuild**: Print an error — Following Babel was chosen for this one because this might help people catch configuration issues where JSX files are being parsed by multiple tools

- Element has an "implicit true" key prop, e.g. `<a key />`:
- Babel: Print an error indicating that "key" props require an explicit value
- TypeScript: Silently omit the "key" prop
- swc: Hard crash
- **esbuild**: Print an error like Babel &mdash; This might help catch legitimate programming mistakes

- Element has spread children, e.g. `<a>{...children}</a>`
- Babel: Print an error stating that React doesn't support spread children
- TypeScript: Use static jsx function and pass children as-is, including spread operator
- swc: same as Babel
- **esbuild**: Same as TypeScript

Also note that TypeScript has some bugs regarding JSX development mode and the generation of `lineNumber` and `columnNumber` values. Babel's values are accurate though, so esbuild's line and column numbers match Babel. Both numbers are 1-based and columns are counted in terms of UTF-16 code units.

This feature was contributed by [@jgoz](https://github.com/jgoz).

## 0.14.50

* Emit `names` in source maps ([#1296](https://github.com/evanw/esbuild/issues/1296))
Expand Down
6 changes: 5 additions & 1 deletion cmd/esbuild/main.go
Expand Up @@ -77,9 +77,13 @@ var helpText = func(colors logger.Colors) string {
incorrect tree-shaking annotations
--inject:F Import the file F into all input files and
automatically replace matching globals with imports
--jsx-dev Use React's automatic runtime in development mode
--jsx-factory=... What to use for JSX instead of React.createElement
--jsx-fragment=... What to use for JSX instead of React.Fragment
--jsx=... Set to "preserve" to disable transforming JSX to JS
--jsx-import-source=... Override the package name for the automatic runtime
(default "react")
--jsx=... Set to "automatic" to use React's automatic runtime
or to "preserve" to disable transforming JSX to JS
--keep-names Preserve "name" on functions and classes
--legal-comments=... Where to place legal comments (none | inline |
eof | linked | external, default eof when bundling
Expand Down
6 changes: 6 additions & 0 deletions internal/bundler/bundler.go
Expand Up @@ -1194,6 +1194,12 @@ func (s *scanner) maybeParseFile(
if len(resolveResult.JSXFragment) > 0 {
optionsClone.JSX.Fragment = config.DefineExpr{Parts: resolveResult.JSXFragment}
}
if resolveResult.JSX != config.TSJSXNone {
optionsClone.JSX.SetOptionsFromTSJSX(resolveResult.JSX)
}
if resolveResult.JSXImportSource != "" {
optionsClone.JSX.ImportSource = resolveResult.JSXImportSource
}
if resolveResult.UseDefineForClassFieldsTS != config.Unspecified {
optionsClone.UseDefineForClassFields = resolveResult.UseDefineForClassFieldsTS
}
Expand Down
82 changes: 82 additions & 0 deletions internal/bundler/bundler_default_test.go
Expand Up @@ -539,6 +539,88 @@ func TestJSXConstantFragments(t *testing.T) {
})
}

func TestJSXAutomaticImportsCommonJS(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.jsx": `
import {jsx, Fragment} from './custom-react'
console.log(<div jsx={jsx}/>, <><Fragment/></>)
`,
"/custom-react.js": `
module.exports = {}
`,
},
entryPaths: []string{"/entry.jsx"},
options: config.Options{
Mode: config.ModeBundle,
JSX: config.JSXOptions{
AutomaticRuntime: true,
},
ExternalSettings: config.ExternalSettings{
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
"react/jsx-runtime": true,
}},
},
AbsOutputFile: "/out.js",
},
})
}

func TestJSXAutomaticImportsES6(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.jsx": `
import {jsx, Fragment} from './custom-react'
console.log(<div jsx={jsx}/>, <><Fragment/></>)
`,
"/custom-react.js": `
export function jsx() {}
export function Fragment() {}
`,
},
entryPaths: []string{"/entry.jsx"},
options: config.Options{
Mode: config.ModeBundle,
JSX: config.JSXOptions{
AutomaticRuntime: true,
},
ExternalSettings: config.ExternalSettings{
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
"react/jsx-runtime": true,
}},
},
AbsOutputFile: "/out.js",
},
})
}

func TestJSXAutomaticSyntaxInJS(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.js": `
console.log(<div/>)
`,
},
entryPaths: []string{"/entry.js"},
options: config.Options{
Mode: config.ModeBundle,
JSX: config.JSXOptions{
AutomaticRuntime: true,
},
ExternalSettings: config.ExternalSettings{
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
"react/jsx-runtime": true,
}},
},
AbsOutputFile: "/out.js",
},
expectedScanLog: `entry.js: ERROR: The JSX syntax extension is not currently enabled
NOTE: The esbuild loader for this file is currently set to "js" but it must be set to "jsx" to be able to parse JSX syntax. ` +
`You can use 'Loader: map[string]api.Loader{".js": api.LoaderJSX}' to do that.
`,
})
}

func TestNodeModules(t *testing.T) {
default_suite.expectBundled(t, bundled{
files: map[string]string{
Expand Down
85 changes: 85 additions & 0 deletions internal/bundler/bundler_tsconfig_test.go
Expand Up @@ -601,6 +601,91 @@ func TestTsConfigNestedJSX(t *testing.T) {
})
}

func TestTsConfigReactJSX(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/entry.tsx": `
console.log(<><div/><div/></>)
`,
"/Users/user/project/tsconfig.json": `
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "notreact"
}
}
`,
},
entryPaths: []string{"/Users/user/project/entry.tsx"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputFile: "/Users/user/project/out.js",
ExternalSettings: config.ExternalSettings{
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
"notreact/jsx-runtime": true,
}},
},
},
})
}

func TestTsConfigReactJSXDev(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/entry.tsx": `
console.log(<><div/><div/></>)
`,
"/Users/user/project/tsconfig.json": `
{
"compilerOptions": {
"jsx": "react-jsxdev"
}
}
`,
},
entryPaths: []string{"/Users/user/project/entry.tsx"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputFile: "/Users/user/project/out.js",
ExternalSettings: config.ExternalSettings{
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
"react/jsx-dev-runtime": true,
}},
},
},
})
}

func TestTsConfigReactJSXWithDevInMainConfig(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
"/Users/user/project/entry.tsx": `
console.log(<><div/><div/></>)
`,
"/Users/user/project/tsconfig.json": `
{
"compilerOptions": {
"jsx": "react-jsx"
}
}
`,
},
entryPaths: []string{"/Users/user/project/entry.tsx"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputFile: "/Users/user/project/out.js",
JSX: config.JSXOptions{
Development: true,
},
ExternalSettings: config.ExternalSettings{
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
"react/jsx-dev-runtime": true,
}},
},
},
})
}

func TestTsconfigJsonBaseUrl(t *testing.T) {
tsconfig_suite.expectBundled(t, bundled{
files: map[string]string{
Expand Down
36 changes: 36 additions & 0 deletions internal/bundler/snapshots/snapshots_default.txt
Expand Up @@ -1415,6 +1415,42 @@ console.log(replace.test);
console.log(collide);
console.log(re_export);

================================================================================
TestJSXAutomaticImportsCommonJS
---------- /out.js ----------
// custom-react.js
var require_custom_react = __commonJS({
"custom-react.js"(exports, module) {
module.exports = {};
}
});

// entry.jsx
var import_custom_react = __toESM(require_custom_react());
import { Fragment as Fragment2, jsx as jsx2 } from "react/jsx-runtime";
console.log(/* @__PURE__ */ jsx2("div", {
jsx: import_custom_react.jsx
}), /* @__PURE__ */ jsx2(Fragment2, {
children: /* @__PURE__ */ jsx2(import_custom_react.Fragment, {})
}));

================================================================================
TestJSXAutomaticImportsES6
---------- /out.js ----------
// custom-react.js
function jsx() {
}
function Fragment() {
}

// entry.jsx
import { Fragment as Fragment2, jsx as jsx2 } from "react/jsx-runtime";
console.log(/* @__PURE__ */ jsx2("div", {
jsx
}), /* @__PURE__ */ jsx2(Fragment2, {
children: /* @__PURE__ */ jsx2(Fragment, {})
}));

================================================================================
TestJSXConstantFragments
---------- /out.js ----------
Expand Down
60 changes: 60 additions & 0 deletions internal/bundler/snapshots/snapshots_tsconfig.txt
Expand Up @@ -314,6 +314,66 @@ function fib(input) {
// Users/user/project/entry.ts
console.log(fib(10));

================================================================================
TestTsConfigReactJSX
---------- /Users/user/project/out.js ----------
// Users/user/project/entry.tsx
import { Fragment, jsx, jsxs } from "notreact/jsx-runtime";
console.log(/* @__PURE__ */ jsxs(Fragment, {
children: [
/* @__PURE__ */ jsx("div", {}),
/* @__PURE__ */ jsx("div", {})
]
}));

================================================================================
TestTsConfigReactJSXDev
---------- /Users/user/project/out.js ----------
// Users/user/project/entry.tsx
import { Fragment, jsxDEV } from "react/jsx-dev-runtime";
console.log(/* @__PURE__ */ jsxDEV(Fragment, {
children: [
/* @__PURE__ */ jsxDEV("div", {}, void 0, false, {
fileName: "Users/user/project/entry.tsx",
lineNumber: 2,
columnNumber: 19
}, this),
/* @__PURE__ */ jsxDEV("div", {}, void 0, false, {
fileName: "Users/user/project/entry.tsx",
lineNumber: 2,
columnNumber: 25
}, this)
]
}, void 0, true, {
fileName: "Users/user/project/entry.tsx",
lineNumber: 2,
columnNumber: 17
}, this));

================================================================================
TestTsConfigReactJSXWithDevInMainConfig
---------- /Users/user/project/out.js ----------
// Users/user/project/entry.tsx
import { Fragment, jsxDEV } from "react/jsx-dev-runtime";
console.log(/* @__PURE__ */ jsxDEV(Fragment, {
children: [
/* @__PURE__ */ jsxDEV("div", {}, void 0, false, {
fileName: "Users/user/project/entry.tsx",
lineNumber: 2,
columnNumber: 19
}, this),
/* @__PURE__ */ jsxDEV("div", {}, void 0, false, {
fileName: "Users/user/project/entry.tsx",
lineNumber: 2,
columnNumber: 25
}, this)
]
}, void 0, true, {
fileName: "Users/user/project/entry.tsx",
lineNumber: 2,
columnNumber: 17
}, this));

================================================================================
TestTsConfigWithStatementAlwaysStrictFalse
---------- /Users/user/project/out.js ----------
Expand Down

5 comments on commit 0137224

@paralin
Copy link

Choose a reason for hiding this comment

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

@jgoz I guess this is intended to be a backwards-incompatible change? After updating, the default options now compile with JSX syntax un-transformed, causing syntax errors.

esbuild test-component.tsx --bundle --format=esm --target=es2020 --outfile=test-component.js

@paralin
Copy link

Choose a reason for hiding this comment

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

Changing jsx: preserve to jsx: react-jsx in tsconfig.json under compilerOptions fixed it.

@jgoz
Copy link
Contributor Author

@jgoz jgoz commented on 0137224 Jul 29, 2022

Choose a reason for hiding this comment

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

@paralin No, it was intended to be backward compatible. Evan consolidated the different JSX modes into the jsx option — maybe there was an unintentional change to the default as part of that?

I’ll look into it if I can find time today.

@paralin
Copy link

Choose a reason for hiding this comment

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

@jgoz No worries, I didn't realize it, but I had "jsx: preserve" in my tsconfig which caused this.

@jgoz
Copy link
Contributor Author

@jgoz jgoz commented on 0137224 Jul 29, 2022

Choose a reason for hiding this comment

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

Ah great, thanks for the update.

Please sign in to comment.