Skip to content

Commit 0137224

Browse files
authored
fix #334: support automatic JSX runtime (#2349)
1 parent 7a268da commit 0137224

23 files changed

+1136
-94
lines changed

CHANGELOG.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,60 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
* 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))
6+
7+
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.
8+
9+
**New CLI flags and API options:**
10+
- `--jsx`, `jsx` — Set this to `"automatic"` to opt in to this new transform
11+
- `--jsx-dev`, `jsxDev` — Toggles development mode for the automatic runtime
12+
- `--jsx-import-source`, `jsxImportSource` — Overrides the root import for runtime functions (default `"react"`)
13+
14+
**New JSX pragma comments:**
15+
- `@jsxRuntime` — Sets the runtime (`automatic` or `classic`)
16+
- `@jsxImportSource` — Sets the import source (only valid with automatic runtime)
17+
18+
The existing `@jsxFragment` and `@jsxFactory` pragma comments are only valid with "classic" runtime.
19+
20+
**TSConfig resolving:**
21+
Along with accepting the new options directly via CLI or API, option inference from `tsconfig.json` compiler options was also implemented:
22+
23+
- `"jsx": "preserve"` or `"jsx": "react-native"` → Same as `--jsx=preserve` in esbuild
24+
- `"jsx": "react"` → Same as `--jsx=transform` in esbuild (which is the default behavior)
25+
- `"jsx": "react-jsx"` → Same as `--jsx=automatic` in esbuild
26+
- `"jsx": "react-jsxdev"` → Same as `--jsx=automatic --jsx-dev` in esbuild
27+
28+
It also reads the value of `"jsxImportSource"` from `tsconfig.json` if specified.
29+
30+
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.
31+
32+
**esbuild vs Babel vs TS vs...**
33+
34+
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:
35+
36+
- When an element has `__source` or `__self` props:
37+
- Babel: Print an error about a deprecated transform plugin
38+
- TypeScript: Allow the props
39+
- swc: Hard crash
40+
- **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
41+
42+
- Element has an "implicit true" key prop, e.g. `<a key />`:
43+
- Babel: Print an error indicating that "key" props require an explicit value
44+
- TypeScript: Silently omit the "key" prop
45+
- swc: Hard crash
46+
- **esbuild**: Print an error like Babel &mdash; This might help catch legitimate programming mistakes
47+
48+
- Element has spread children, e.g. `<a>{...children}</a>`
49+
- Babel: Print an error stating that React doesn't support spread children
50+
- TypeScript: Use static jsx function and pass children as-is, including spread operator
51+
- swc: same as Babel
52+
- **esbuild**: Same as TypeScript
53+
54+
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.
55+
56+
This feature was contributed by [@jgoz](https://github.com/jgoz).
57+
358
## 0.14.50
459

560
* Emit `names` in source maps ([#1296](https://github.com/evanw/esbuild/issues/1296))

cmd/esbuild/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,13 @@ var helpText = func(colors logger.Colors) string {
7777
incorrect tree-shaking annotations
7878
--inject:F Import the file F into all input files and
7979
automatically replace matching globals with imports
80+
--jsx-dev Use React's automatic runtime in development mode
8081
--jsx-factory=... What to use for JSX instead of React.createElement
8182
--jsx-fragment=... What to use for JSX instead of React.Fragment
82-
--jsx=... Set to "preserve" to disable transforming JSX to JS
83+
--jsx-import-source=... Override the package name for the automatic runtime
84+
(default "react")
85+
--jsx=... Set to "automatic" to use React's automatic runtime
86+
or to "preserve" to disable transforming JSX to JS
8387
--keep-names Preserve "name" on functions and classes
8488
--legal-comments=... Where to place legal comments (none | inline |
8589
eof | linked | external, default eof when bundling

internal/bundler/bundler.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,6 +1194,12 @@ func (s *scanner) maybeParseFile(
11941194
if len(resolveResult.JSXFragment) > 0 {
11951195
optionsClone.JSX.Fragment = config.DefineExpr{Parts: resolveResult.JSXFragment}
11961196
}
1197+
if resolveResult.JSX != config.TSJSXNone {
1198+
optionsClone.JSX.SetOptionsFromTSJSX(resolveResult.JSX)
1199+
}
1200+
if resolveResult.JSXImportSource != "" {
1201+
optionsClone.JSX.ImportSource = resolveResult.JSXImportSource
1202+
}
11971203
if resolveResult.UseDefineForClassFieldsTS != config.Unspecified {
11981204
optionsClone.UseDefineForClassFields = resolveResult.UseDefineForClassFieldsTS
11991205
}

internal/bundler/bundler_default_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,88 @@ func TestJSXConstantFragments(t *testing.T) {
539539
})
540540
}
541541

542+
func TestJSXAutomaticImportsCommonJS(t *testing.T) {
543+
default_suite.expectBundled(t, bundled{
544+
files: map[string]string{
545+
"/entry.jsx": `
546+
import {jsx, Fragment} from './custom-react'
547+
console.log(<div jsx={jsx}/>, <><Fragment/></>)
548+
`,
549+
"/custom-react.js": `
550+
module.exports = {}
551+
`,
552+
},
553+
entryPaths: []string{"/entry.jsx"},
554+
options: config.Options{
555+
Mode: config.ModeBundle,
556+
JSX: config.JSXOptions{
557+
AutomaticRuntime: true,
558+
},
559+
ExternalSettings: config.ExternalSettings{
560+
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
561+
"react/jsx-runtime": true,
562+
}},
563+
},
564+
AbsOutputFile: "/out.js",
565+
},
566+
})
567+
}
568+
569+
func TestJSXAutomaticImportsES6(t *testing.T) {
570+
default_suite.expectBundled(t, bundled{
571+
files: map[string]string{
572+
"/entry.jsx": `
573+
import {jsx, Fragment} from './custom-react'
574+
console.log(<div jsx={jsx}/>, <><Fragment/></>)
575+
`,
576+
"/custom-react.js": `
577+
export function jsx() {}
578+
export function Fragment() {}
579+
`,
580+
},
581+
entryPaths: []string{"/entry.jsx"},
582+
options: config.Options{
583+
Mode: config.ModeBundle,
584+
JSX: config.JSXOptions{
585+
AutomaticRuntime: true,
586+
},
587+
ExternalSettings: config.ExternalSettings{
588+
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
589+
"react/jsx-runtime": true,
590+
}},
591+
},
592+
AbsOutputFile: "/out.js",
593+
},
594+
})
595+
}
596+
597+
func TestJSXAutomaticSyntaxInJS(t *testing.T) {
598+
default_suite.expectBundled(t, bundled{
599+
files: map[string]string{
600+
"/entry.js": `
601+
console.log(<div/>)
602+
`,
603+
},
604+
entryPaths: []string{"/entry.js"},
605+
options: config.Options{
606+
Mode: config.ModeBundle,
607+
JSX: config.JSXOptions{
608+
AutomaticRuntime: true,
609+
},
610+
ExternalSettings: config.ExternalSettings{
611+
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
612+
"react/jsx-runtime": true,
613+
}},
614+
},
615+
AbsOutputFile: "/out.js",
616+
},
617+
expectedScanLog: `entry.js: ERROR: The JSX syntax extension is not currently enabled
618+
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. ` +
619+
`You can use 'Loader: map[string]api.Loader{".js": api.LoaderJSX}' to do that.
620+
`,
621+
})
622+
}
623+
542624
func TestNodeModules(t *testing.T) {
543625
default_suite.expectBundled(t, bundled{
544626
files: map[string]string{

internal/bundler/bundler_tsconfig_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,91 @@ func TestTsConfigNestedJSX(t *testing.T) {
601601
})
602602
}
603603

604+
func TestTsConfigReactJSX(t *testing.T) {
605+
tsconfig_suite.expectBundled(t, bundled{
606+
files: map[string]string{
607+
"/Users/user/project/entry.tsx": `
608+
console.log(<><div/><div/></>)
609+
`,
610+
"/Users/user/project/tsconfig.json": `
611+
{
612+
"compilerOptions": {
613+
"jsx": "react-jsx",
614+
"jsxImportSource": "notreact"
615+
}
616+
}
617+
`,
618+
},
619+
entryPaths: []string{"/Users/user/project/entry.tsx"},
620+
options: config.Options{
621+
Mode: config.ModeBundle,
622+
AbsOutputFile: "/Users/user/project/out.js",
623+
ExternalSettings: config.ExternalSettings{
624+
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
625+
"notreact/jsx-runtime": true,
626+
}},
627+
},
628+
},
629+
})
630+
}
631+
632+
func TestTsConfigReactJSXDev(t *testing.T) {
633+
tsconfig_suite.expectBundled(t, bundled{
634+
files: map[string]string{
635+
"/Users/user/project/entry.tsx": `
636+
console.log(<><div/><div/></>)
637+
`,
638+
"/Users/user/project/tsconfig.json": `
639+
{
640+
"compilerOptions": {
641+
"jsx": "react-jsxdev"
642+
}
643+
}
644+
`,
645+
},
646+
entryPaths: []string{"/Users/user/project/entry.tsx"},
647+
options: config.Options{
648+
Mode: config.ModeBundle,
649+
AbsOutputFile: "/Users/user/project/out.js",
650+
ExternalSettings: config.ExternalSettings{
651+
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
652+
"react/jsx-dev-runtime": true,
653+
}},
654+
},
655+
},
656+
})
657+
}
658+
659+
func TestTsConfigReactJSXWithDevInMainConfig(t *testing.T) {
660+
tsconfig_suite.expectBundled(t, bundled{
661+
files: map[string]string{
662+
"/Users/user/project/entry.tsx": `
663+
console.log(<><div/><div/></>)
664+
`,
665+
"/Users/user/project/tsconfig.json": `
666+
{
667+
"compilerOptions": {
668+
"jsx": "react-jsx"
669+
}
670+
}
671+
`,
672+
},
673+
entryPaths: []string{"/Users/user/project/entry.tsx"},
674+
options: config.Options{
675+
Mode: config.ModeBundle,
676+
AbsOutputFile: "/Users/user/project/out.js",
677+
JSX: config.JSXOptions{
678+
Development: true,
679+
},
680+
ExternalSettings: config.ExternalSettings{
681+
PreResolve: config.ExternalMatchers{Exact: map[string]bool{
682+
"react/jsx-dev-runtime": true,
683+
}},
684+
},
685+
},
686+
})
687+
}
688+
604689
func TestTsconfigJsonBaseUrl(t *testing.T) {
605690
tsconfig_suite.expectBundled(t, bundled{
606691
files: map[string]string{

internal/bundler/snapshots/snapshots_default.txt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,42 @@ console.log(replace.test);
14151415
console.log(collide);
14161416
console.log(re_export);
14171417

1418+
================================================================================
1419+
TestJSXAutomaticImportsCommonJS
1420+
---------- /out.js ----------
1421+
// custom-react.js
1422+
var require_custom_react = __commonJS({
1423+
"custom-react.js"(exports, module) {
1424+
module.exports = {};
1425+
}
1426+
});
1427+
1428+
// entry.jsx
1429+
var import_custom_react = __toESM(require_custom_react());
1430+
import { Fragment as Fragment2, jsx as jsx2 } from "react/jsx-runtime";
1431+
console.log(/* @__PURE__ */ jsx2("div", {
1432+
jsx: import_custom_react.jsx
1433+
}), /* @__PURE__ */ jsx2(Fragment2, {
1434+
children: /* @__PURE__ */ jsx2(import_custom_react.Fragment, {})
1435+
}));
1436+
1437+
================================================================================
1438+
TestJSXAutomaticImportsES6
1439+
---------- /out.js ----------
1440+
// custom-react.js
1441+
function jsx() {
1442+
}
1443+
function Fragment() {
1444+
}
1445+
1446+
// entry.jsx
1447+
import { Fragment as Fragment2, jsx as jsx2 } from "react/jsx-runtime";
1448+
console.log(/* @__PURE__ */ jsx2("div", {
1449+
jsx
1450+
}), /* @__PURE__ */ jsx2(Fragment2, {
1451+
children: /* @__PURE__ */ jsx2(Fragment, {})
1452+
}));
1453+
14181454
================================================================================
14191455
TestJSXConstantFragments
14201456
---------- /out.js ----------

internal/bundler/snapshots/snapshots_tsconfig.txt

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,66 @@ function fib(input) {
314314
// Users/user/project/entry.ts
315315
console.log(fib(10));
316316

317+
================================================================================
318+
TestTsConfigReactJSX
319+
---------- /Users/user/project/out.js ----------
320+
// Users/user/project/entry.tsx
321+
import { Fragment, jsx, jsxs } from "notreact/jsx-runtime";
322+
console.log(/* @__PURE__ */ jsxs(Fragment, {
323+
children: [
324+
/* @__PURE__ */ jsx("div", {}),
325+
/* @__PURE__ */ jsx("div", {})
326+
]
327+
}));
328+
329+
================================================================================
330+
TestTsConfigReactJSXDev
331+
---------- /Users/user/project/out.js ----------
332+
// Users/user/project/entry.tsx
333+
import { Fragment, jsxDEV } from "react/jsx-dev-runtime";
334+
console.log(/* @__PURE__ */ jsxDEV(Fragment, {
335+
children: [
336+
/* @__PURE__ */ jsxDEV("div", {}, void 0, false, {
337+
fileName: "Users/user/project/entry.tsx",
338+
lineNumber: 2,
339+
columnNumber: 19
340+
}, this),
341+
/* @__PURE__ */ jsxDEV("div", {}, void 0, false, {
342+
fileName: "Users/user/project/entry.tsx",
343+
lineNumber: 2,
344+
columnNumber: 25
345+
}, this)
346+
]
347+
}, void 0, true, {
348+
fileName: "Users/user/project/entry.tsx",
349+
lineNumber: 2,
350+
columnNumber: 17
351+
}, this));
352+
353+
================================================================================
354+
TestTsConfigReactJSXWithDevInMainConfig
355+
---------- /Users/user/project/out.js ----------
356+
// Users/user/project/entry.tsx
357+
import { Fragment, jsxDEV } from "react/jsx-dev-runtime";
358+
console.log(/* @__PURE__ */ jsxDEV(Fragment, {
359+
children: [
360+
/* @__PURE__ */ jsxDEV("div", {}, void 0, false, {
361+
fileName: "Users/user/project/entry.tsx",
362+
lineNumber: 2,
363+
columnNumber: 19
364+
}, this),
365+
/* @__PURE__ */ jsxDEV("div", {}, void 0, false, {
366+
fileName: "Users/user/project/entry.tsx",
367+
lineNumber: 2,
368+
columnNumber: 25
369+
}, this)
370+
]
371+
}, void 0, true, {
372+
fileName: "Users/user/project/entry.tsx",
373+
lineNumber: 2,
374+
columnNumber: 17
375+
}, this));
376+
317377
================================================================================
318378
TestTsConfigWithStatementAlwaysStrictFalse
319379
---------- /Users/user/project/out.js ----------

0 commit comments

Comments
 (0)