Skip to content

Commit

Permalink
Add "preserve" mode for JSX (#788)
Browse files Browse the repository at this point in the history
Fixes #780
Fixes #559

This PR adds support for preserving JSX as-is. Since the parser need to know if
JSX is enabled or not, preserving JSX is accomplished by keeping `"jsx"` as a
transform, but setting the `jsxRuntime` mode to `"preserve"`.

Most of the details just work: the imports and typescript transform are mostly
able to remove type syntax and transform imported names properly. The one
missing case was handling situations like `<div>`, which is not actually an
identifier access and thus should not be transformed by the imports transform.
This required adding a special case to the parser to remove the identifier role
in that case.

For now, the ts-node integration does not recognize this option because Node
wouldn't be able to recognize JSX anyway.
  • Loading branch information
alangpierce committed Mar 26, 2023
1 parent 365fccc commit 5636395
Show file tree
Hide file tree
Showing 9 changed files with 119 additions and 38 deletions.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,9 @@ for methodology and caveats.
The main configuration option in Sucrase is an array of transform names. These
transforms are available:

* **jsx**: Transforms JSX syntax to `React.createElement`, e.g. `<div a={b} />`
becomes `React.createElement('div', {a: b})`. Behaves like Babel 7's
[React preset](https://github.com/babel/babel/tree/main/packages/babel-preset-react),
including adding `createReactClass` display names and JSX context information.
* **jsx**: Enables JSX syntax. By default, JSX is transformed to `React.createClass`,
but may be preserved or transformed to `_jsx()` by setting the `jsxRuntime` option.
Also adds `createReactClass` display names and JSX context information.
* **typescript**: Compiles TypeScript code to JavaScript, removing type
annotations and handling features like enums. Does not check types. Sucrase
transforms each file independently, so you should enable the `isolatedModules`
Expand Down Expand Up @@ -134,7 +133,7 @@ by your JS runtime. For example:
By default, JSX is compiled to React functions in development mode. This can be
configured with a few options:

* **jsxRuntime**: A string specifying the transform mode, which can be one of two values:
* **jsxRuntime**: A string specifying the transform mode, which can be one of three values:
* `"classic"` (default): The original JSX transform that calls `React.createElement` by default.
To configure for non-React use cases, specify:
* **jsxPragma**: Element creation function, defaults to `React.createElement`.
Expand All @@ -143,6 +142,7 @@ configured with a few options:
introduced with React 17, which calls `jsx` functions and auto-adds import statements.
To configure for non-React use cases, specify:
* **jsxImportSource**: Package name for auto-generated import statements, defaults to `react`.
* `"preserve"`: Don't transform JSX, and instead emit it as-is in the output code.
* **production**: If `true`, use production version of functions and don't include debugging
information. When using React in production mode with the automatic transform, this *must* be
set to true to avoid an error about `jsxDEV` being missing.
Expand Down
2 changes: 1 addition & 1 deletion src/Options-gen-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const SourceMapOptions = t.iface([], {
export const Options = t.iface([], {
transforms: t.array("Transform"),
disableESTransforms: t.opt("boolean"),
jsxRuntime: t.opt(t.union(t.lit("classic"), t.lit("automatic"))),
jsxRuntime: t.opt(t.union(t.lit("classic"), t.lit("automatic"), t.lit("preserve"))),
production: t.opt("boolean"),
jsxImportSource: t.opt("string"),
jsxPragma: t.opt("string"),
Expand Down
13 changes: 7 additions & 6 deletions src/Options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,15 @@ export interface Options {
*/
disableESTransforms?: boolean;
/**
* Transformation mode for the JSX transform. The automatic transform refers
* to the transform behavior released with React 17, where the `jsx` function
* (or a variation) is automatically imported. The classic transform refers to
* the previous behavior using `React.createElement`.
* Transformation mode for the JSX transform.
* - "classic" refers to the original behavior using `React.createElement`.
* - "automatic" refers to the transform behavior released with React 17,
* where the `jsx` function (or a variation) is automatically imported.
* - "preserve" leaves the JSX as-is.
*
* Default value: "classic"
* Default value: "classic".
*/
jsxRuntime?: "classic" | "automatic";
jsxRuntime?: "classic" | "automatic" | "preserve";
/**
* Compile code for production use. Currently only applies to the JSX
* transform.
Expand Down
14 changes: 14 additions & 0 deletions src/parser/plugins/jsx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,25 @@ function jsxParseNamespacedName(identifierRole: IdentifierRole): void {
// Parses element name in any form - namespaced, member
// or single identifier.
function jsxParseElementName(): void {
const firstTokenIndex = state.tokens.length;
jsxParseNamespacedName(IdentifierRole.Access);
let hadDot = false;
while (match(tt.dot)) {
hadDot = true;
nextJSXTagToken();
jsxParseIdentifier();
}
// For tags like <div> with a lowercase letter and no dots, the name is
// actually *not* an identifier access, since it's referring to a built-in
// tag name. Remove the identifier role in this case so that it's not
// accidentally transformed by the imports transform when preserving JSX.
if (!hadDot) {
const firstToken = state.tokens[firstTokenIndex];
const firstChar = input.charCodeAt(firstToken.start);
if (firstChar >= charCodes.lowercaseA && firstChar <= charCodes.lowercaseZ) {
firstToken.identifierRole = null;
}
}
}

// Parses any type of JSX attribute value.
Expand Down
2 changes: 1 addition & 1 deletion src/transformers/JSXTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ export default class JSXTransformer extends Transformer {
* Spec for identifiers: https://tc39.github.io/ecma262/#prod-IdentifierStart.
*
* Really only treat anything starting with a-z as tag names. `_`, `$`, `é`
* should be treated as copmonent names
* should be treated as component names
*/
export function startsWithLowerCase(s: string): boolean {
const firstChar = s.charCodeAt(0);
Expand Down
8 changes: 5 additions & 3 deletions src/transformers/RootTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ export default class RootTransformer {
}

if (transforms.includes("jsx")) {
this.transformers.push(
new JSXTransformer(this, tokenProcessor, importProcessor, this.nameManager, options),
);
if (options.jsxRuntime !== "preserve") {
this.transformers.push(
new JSXTransformer(this, tokenProcessor, importProcessor, this.nameManager, options),
);
}
this.transformers.push(
new ReactDisplayNameTransformer(this, tokenProcessor, importProcessor, options),
);
Expand Down
58 changes: 57 additions & 1 deletion test/jsx-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {throws} from "assert";
import {transform, Options} from "../src";
import {IMPORT_DEFAULT_PREFIX, JSX_PREFIX} from "./prefixes";
import * as util from "./util";
import {jsxDevArgs} from "./util";
import {assertResult, jsxDevArgs} from "./util";

const {devProps} = util;

Expand Down Expand Up @@ -1169,4 +1169,60 @@ describe("transform JSX", () => {
},
);
});

it("allows preserving JSX", () => {
assertResult(
`
<div />
`,
`
<div />
`,
{transforms: ["jsx"], jsxRuntime: "preserve"},
);
});

it("transforms valid JSX names with imports transform when preserving JSX", () => {
assertResult(
`
import {foo, Bar, baz, abc, test, def, ghi} from "./utils";
function render() {
return (
<>
<foo />
<Bar />
<baz.abc />
<div test={def}>{ghi}</div>
</>
);
}
`,
`"use strict";
var _utils = require('./utils');
function render() {
return (
<>
<foo />
<_utils.Bar />
<_utils.baz.abc />
<div test={_utils.def}>{_utils.ghi}</div>
</>
);
}
`,
{transforms: ["jsx", "imports"], jsxRuntime: "preserve"},
);
});

it("removes JSX TS type arguments when preserving JSX", () => {
assertResult(
`
<Foo<number> />
`,
`
<Foo />
`,
{transforms: ["jsx", "typescript"], jsxRuntime: "preserve"},
);
});
});
14 changes: 8 additions & 6 deletions website/src/SucraseOptionsBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function JSXRuntimeOption({options, onUpdateOptions}: JSXRuntimeOptionProps): JS
<span className={css(styles.optionName)}>
jsxRuntime:{" "}
<SimpleSelect
options={["classic", "automatic"]}
options={["classic", "automatic", "preserve"]}
value={options.jsxRuntime}
onChange={(value) => {
onUpdateOptions({...options, jsxRuntime: value});
Expand Down Expand Up @@ -164,11 +164,13 @@ export default function SucraseOptionsBox({
{options.transforms.includes("jsx") && (
<>
<JSXRuntimeOption options={options} onUpdateOptions={onUpdateOptions} />
<BooleanOption
optionName="production"
options={options}
onUpdateOptions={onUpdateOptions}
/>
{options.jsxRuntime !== "preserve" && (
<BooleanOption
optionName="production"
options={options}
onUpdateOptions={onUpdateOptions}
/>
)}
{options.jsxRuntime === "classic" && (
<>
<StringOption
Expand Down
36 changes: 21 additions & 15 deletions website/src/Worker.worker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable no-restricted-globals */
import * as Sucrase from "sucrase";
import type {ModuleKind, JsxEmit} from "typescript";
import type {JsxEmit, ModuleKind} from "typescript";

import getTokens from "./getTokens";
import {compressCode} from "./URLHashState";
Expand Down Expand Up @@ -114,20 +114,24 @@ function runBabel(): {code: string; time: number | null} {
const presets: Array<string | [string, unknown]> = [];

if (sucraseOptions.transforms.includes("jsx")) {
presets.push([
"react",
{
development: !sucraseOptions.production,
runtime: sucraseOptions.jsxRuntime,
...(sucraseOptions.jsxRuntime === "automatic" && {
importSource: sucraseOptions.jsxImportSource,
}),
...(sucraseOptions.jsxRuntime === "classic" && {
pragma: sucraseOptions.jsxPragma,
pragmaFrag: sucraseOptions.jsxFragmentPragma,
}),
},
]);
if (sucraseOptions.jsxRuntime === "preserve") {
plugins.push("syntax-jsx");
} else {
presets.push([
"react",
{
development: !sucraseOptions.production,
runtime: sucraseOptions.jsxRuntime,
...(sucraseOptions.jsxRuntime === "automatic" && {
importSource: sucraseOptions.jsxImportSource,
}),
...(sucraseOptions.jsxRuntime === "classic" && {
pragma: sucraseOptions.jsxPragma,
pragmaFrag: sucraseOptions.jsxFragmentPragma,
}),
},
]);
}
}
if (sucraseOptions.transforms.includes("typescript")) {
presets.push(["typescript", {allowDeclareFields: true}]);
Expand Down Expand Up @@ -205,6 +209,8 @@ function runTypeScript(): {code: string; time: number | null} {
if (sucraseOptions.transforms.includes("jsx")) {
if (sucraseOptions.jsxRuntime === "classic") {
jsxEmit = JsxEmit.React;
} else if (sucraseOptions.jsxRuntime === "preserve") {
jsxEmit = JsxEmit.Preserve;
} else if (sucraseOptions.production) {
jsxEmit = JsxEmit.ReactJSX;
} else {
Expand Down

0 comments on commit 5636395

Please sign in to comment.