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

fix #334: support automatic JSX runtime #2349

Merged
merged 24 commits into from
Jul 28, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading