diff --git a/packages/babel-generator/src/generators/jsx.js b/packages/babel-generator/src/generators/jsx.js index 32db95c327..5504f47010 100644 --- a/packages/babel-generator/src/generators/jsx.js +++ b/packages/babel-generator/src/generators/jsx.js @@ -92,3 +92,25 @@ export function JSXClosingElement(node: Object) { } export function JSXEmptyExpression() {} + +export function JSXFragment(node: Object) { + this.print(node.openingFragment, node); + + this.indent(); + for (const child of (node.children: Array)) { + this.print(child, node); + } + this.dedent(); + + this.print(node.closingFragment, node); +} + +export function JSXOpeningFragment() { + this.token("<"); + this.token(">"); +} + +export function JSXClosingFragment() { + this.token(""); +} diff --git a/packages/babel-generator/test/fixtures/types/XJSFragment-XJSOpeningFragment-XJSClosingFragment/actual.js b/packages/babel-generator/test/fixtures/types/XJSFragment-XJSOpeningFragment-XJSClosingFragment/actual.js new file mode 100644 index 0000000000..4ed9afe36e --- /dev/null +++ b/packages/babel-generator/test/fixtures/types/XJSFragment-XJSOpeningFragment-XJSClosingFragment/actual.js @@ -0,0 +1,6 @@ +<>; + +< +> + text +; diff --git a/packages/babel-generator/test/fixtures/types/XJSFragment-XJSOpeningFragment-XJSClosingFragment/expected.js b/packages/babel-generator/test/fixtures/types/XJSFragment-XJSOpeningFragment-XJSClosingFragment/expected.js new file mode 100644 index 0000000000..835309619b --- /dev/null +++ b/packages/babel-generator/test/fixtures/types/XJSFragment-XJSOpeningFragment-XJSClosingFragment/expected.js @@ -0,0 +1,4 @@ +<>; +<> + text +; \ No newline at end of file diff --git a/packages/babel-generator/test/fixtures/types/XJSFragment-XJSOpeningFragment-XJSClosingFragment/options.json b/packages/babel-generator/test/fixtures/types/XJSFragment-XJSOpeningFragment-XJSClosingFragment/options.json new file mode 100644 index 0000000000..da9cfe1340 --- /dev/null +++ b/packages/babel-generator/test/fixtures/types/XJSFragment-XJSOpeningFragment-XJSClosingFragment/options.json @@ -0,0 +1 @@ +{ "plugins": ["jsx" ] } \ No newline at end of file diff --git a/packages/babel-helper-builder-react-jsx/README.md b/packages/babel-helper-builder-react-jsx/README.md index 51771d7f44..1701f15f51 100644 --- a/packages/babel-helper-builder-react-jsx/README.md +++ b/packages/babel-helper-builder-react-jsx/README.md @@ -8,8 +8,6 @@ type ElementState = { tagName: string; // raw string tag name args: Array; // array of call arguments call?: Object; // optional call property that can be set to override the call expression returned - pre?: Function; // function called with (state: ElementState) before building attribs - post?: Function; // function called with (state: ElementState) after building attribs }; require("@babel/helper-builder-react-jsx")({ @@ -18,11 +16,13 @@ require("@babel/helper-builder-react-jsx")({ }, pre: function (state: ElementState) { - // called before building the element + // function called with (state: ElementState) before building attribs }, post: function (state: ElementState) { - // called after building the element - } + // function called with (state: ElementState) after building attribs + }, + + compat?: boolean // true if React is in compat mode }); ``` diff --git a/packages/babel-helper-builder-react-jsx/src/index.js b/packages/babel-helper-builder-react-jsx/src/index.js index 9e612e3259..6307db4608 100644 --- a/packages/babel-helper-builder-react-jsx/src/index.js +++ b/packages/babel-helper-builder-react-jsx/src/index.js @@ -3,11 +3,9 @@ import * as t from "@babel/types"; type ElementState = { tagExpr: Object, // tag node - tagName: string, // raw string tag name + tagName: ?string, // raw string tag name args: Array, // array of call arguments call?: Object, // optional call property that can be set to override the call expression returned - pre?: Function, // function called with (state: ElementState) before building attribs - post?: Function, // function called with (state: ElementState) after building attribs }; export default function(opts) { @@ -30,6 +28,20 @@ You can turn on the 'throwIfNamespace' flag to bypass this warning.`, }, }; + visitor.JSXFragment = { + exit(path, file) { + if (opts.compat) { + throw path.buildCodeFrameError( + "Fragment tags are only supported in React 16 and up.", + ); + } + const callExpr = buildFragmentCall(path, file); + if (callExpr) { + path.replaceWith(t.inherits(callExpr, path.node)); + } + }, + }; + return visitor; function convertJSXIdentifier(node, parent) { @@ -188,4 +200,35 @@ You can turn on the 'throwIfNamespace' flag to bypass this warning.`, return attribs; } + + function buildFragmentCall(path, file) { + if (opts.filter && !opts.filter(path.node, file)) return; + + const openingPath = path.get("openingElement"); + openingPath.parent.children = t.react.buildChildren(openingPath.parent); + + const args = []; + const tagName = null; + const tagExpr = file.get("jsxFragIdentifier")(); + + const state: ElementState = { + tagExpr: tagExpr, + tagName: tagName, + args: args, + }; + + if (opts.pre) { + opts.pre(state, file); + } + + // no attributes are allowed with <> syntax + args.push(t.nullLiteral(), ...path.node.children); + + if (opts.post) { + opts.post(state, file); + } + + file.set("usedFragment", true); + return state.call || t.callExpression(state.callee, args); + } } diff --git a/packages/babel-plugin-transform-react-jsx-compat/src/index.js b/packages/babel-plugin-transform-react-jsx-compat/src/index.js index 51ca87ae48..aadd7eb6a4 100644 --- a/packages/babel-plugin-transform-react-jsx-compat/src/index.js +++ b/packages/babel-plugin-transform-react-jsx-compat/src/index.js @@ -23,6 +23,7 @@ export default function({ types: t }) { ); } }, + compat: true, }), }; } diff --git a/packages/babel-plugin-transform-react-jsx/README.md b/packages/babel-plugin-transform-react-jsx/README.md index b8cd2698ed..83f757e0a6 100644 --- a/packages/babel-plugin-transform-react-jsx/README.md +++ b/packages/babel-plugin-transform-react-jsx/README.md @@ -46,12 +46,76 @@ var profile =
var dom = require("deku").dom; -var profile = dom( "div", null, +var profile = dom("div", null, dom("img", { src: "avatar.png", className: "profile" }), dom("h3", null, [user.firstName, user.lastName].join(" ")) ); ``` +### Fragments + +Fragments are a feature available in React 16.2.0+. + +#### React + +**In** + +```javascript +var descriptions = items.map(item => ( + <> +
{item.name}
+
{item.value}
+ +)); +``` + +**Out** + +```javascript +var descriptions = items.map(item => React.createElement( + React.Fragment, + null, + React.createElement("dt", null, item.name), + React.createElement("dd", null, item.value) +)); +``` + +#### Custom + +**In** + +```javascript +/** @jsx dom */ +/** @jsxFrag DomFrag */ + +var { dom, DomFrag } = require("deku"); // DomFrag is fictional! + +var descriptions = items.map(item => ( + <> +
{item.name}
+
{item.value}
+ +)); +``` + +**Out** + +```javascript +/** @jsx dom */ +/** @jsxFrag DomFrag */ + +var { dom, DomFrag } = require("deku"); // DomFrag is fictional! + +var descriptions = items.map(item => dom( + DomFrag, + null, + dom("dt", null, item.name), + dom("dd", null, item.value) +)); +``` + +Note that if a custom pragma is specified, then a custom fragment pragma must also be specified if the `<>` is used. Otherwise, an error will be thrown. + ## Installation ```sh @@ -79,6 +143,7 @@ With options: "plugins": [ ["@babel/transform-react-jsx", { "pragma": "dom", // default pragma is React.createElement + "pragmaFrag": "DomFrag", // default is React.Fragment "throwIfNamespace": false // defaults to true }] ] @@ -109,6 +174,12 @@ Replace the function used when compiling JSX expressions. Note that the `@jsx React.DOM` pragma has been deprecated as of React v0.12 +### `pragmaFrag` + +`string`, defaults to `React.Fragment`. + +Replace the component used when compiling JSX fragments. + ### `useBuiltIns` `boolean`, defaults to `false`. diff --git a/packages/babel-plugin-transform-react-jsx/src/index.js b/packages/babel-plugin-transform-react-jsx/src/index.js index c3beb62cdd..a4c485ea04 100644 --- a/packages/babel-plugin-transform-react-jsx/src/index.js +++ b/packages/babel-plugin-transform-react-jsx/src/index.js @@ -2,11 +2,23 @@ import jsx from "@babel/plugin-syntax-jsx"; import helper from "@babel/helper-builder-react-jsx"; export default function({ types: t }, options) { - const pragma = options.pragma || "React.createElement"; - const throwIfNamespace = + const THROW_IF_NAMESPACE = options.throwIfNamespace === undefined ? true : !!options.throwIfNamespace; + const PRAGMA_DEFAULT = options.pragma || "React.createElement"; + const PRAGMA_FRAG_DEFAULT = options.pragmaFrag || "React.Fragment"; + const JSX_ANNOTATION_REGEX = /\*?\s*@jsx\s+([^\s]+)/; + const JSX_FRAG_ANNOTATION_REGEX = /\*?\s*@jsxFrag\s+([^\s]+)/; + + // returns a closure that returns an identifier or memberExpression node + // based on the given id + const createIdentifierParser = (id: string) => () => { + return id + .split(".") + .map(name => t.identifier(name)) + .reduce((object, property) => t.memberExpression(object, property)); + }; const visitor = helper({ pre(state) { @@ -23,27 +35,49 @@ export default function({ types: t }, options) { state.callee = pass.get("jsxIdentifier")(); }, - throwIfNamespace, + throwIfNamespace: THROW_IF_NAMESPACE, }); - visitor.Program = function(path, state) { - const { file } = state; + visitor.Program = { + enter(path, state) { + const { file } = state; - let id = pragma; - for (const comment of (file.ast.comments: Array)) { - const matches = JSX_ANNOTATION_REGEX.exec(comment.value); - if (matches) { - id = matches[1]; - break; + let pragma = PRAGMA_DEFAULT; + let pragmaFrag = PRAGMA_FRAG_DEFAULT; + let pragmaSet = !!options.pragma; + let pragmaFragSet = !!options.pragmaFrag; + + for (const comment of (file.ast.comments: Array)) { + const jsxMatches = JSX_ANNOTATION_REGEX.exec(comment.value); + if (jsxMatches) { + pragma = jsxMatches[1]; + pragmaSet = true; + } + const jsxFragMatches = JSX_FRAG_ANNOTATION_REGEX.exec(comment.value); + if (jsxFragMatches) { + pragmaFrag = jsxFragMatches[1]; + pragmaFragSet = true; + } } - } - state.set("jsxIdentifier", () => - id - .split(".") - .map(name => t.identifier(name)) - .reduce((object, property) => t.memberExpression(object, property)), - ); + state.set("jsxIdentifier", createIdentifierParser(pragma)); + state.set("jsxFragIdentifier", createIdentifierParser(pragmaFrag)); + state.set("usedFragment", false); + state.set("pragmaSet", pragmaSet); + state.set("pragmaFragSet", pragmaFragSet); + }, + exit(path, state) { + if ( + state.get("pragmaSet") && + state.get("usedFragment") && + !state.get("pragmaFragSet") + ) { + throw new Error( + "transform-react-jsx: pragma has been set but " + + "pragmafrag has not been set", + ); + } + }, }; visitor.JSXAttribute = function(path) { diff --git a/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-nested-fragments/actual.js b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-nested-fragments/actual.js new file mode 100644 index 0000000000..73b5cad558 --- /dev/null +++ b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-nested-fragments/actual.js @@ -0,0 +1,12 @@ +
+ < > + <> + Hello + world + + <> + Goodbye + world + + +
diff --git a/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-nested-fragments/expected.js b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-nested-fragments/expected.js new file mode 100644 index 0000000000..e4f93efcc1 --- /dev/null +++ b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-nested-fragments/expected.js @@ -0,0 +1 @@ +React.createElement("div", null, React.createElement(React.Fragment, null, React.createElement(React.Fragment, null, React.createElement("span", null, "Hello"), React.createElement("span", null, "world")), React.createElement(React.Fragment, null, React.createElement("span", null, "Goodbye"), React.createElement("span", null, "world")))); diff --git a/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-no-pragmafrag-if-frag-unused/actual.js b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-no-pragmafrag-if-frag-unused/actual.js new file mode 100644 index 0000000000..4da208d348 --- /dev/null +++ b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-no-pragmafrag-if-frag-unused/actual.js @@ -0,0 +1,3 @@ +/** @jsx dom */ + +
no fragment is used
diff --git a/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-no-pragmafrag-if-frag-unused/expected.js b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-no-pragmafrag-if-frag-unused/expected.js new file mode 100644 index 0000000000..bac7ff4042 --- /dev/null +++ b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-no-pragmafrag-if-frag-unused/expected.js @@ -0,0 +1,2 @@ +/** @jsx dom */ +dom("div", null, "no fragment is used"); diff --git a/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-pragmafrag-and-frag/actual.js b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-pragmafrag-and-frag/actual.js new file mode 100644 index 0000000000..3f029e60c7 --- /dev/null +++ b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-pragmafrag-and-frag/actual.js @@ -0,0 +1,4 @@ +/** @jsx dom */ +/** @jsxFrag DomFrag */ + +<> diff --git a/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-pragmafrag-and-frag/expected.js b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-pragmafrag-and-frag/expected.js new file mode 100644 index 0000000000..d5a2a9bac2 --- /dev/null +++ b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/should-allow-pragmafrag-and-frag/expected.js @@ -0,0 +1,4 @@ +/** @jsx dom */ + +/** @jsxFrag DomFrag */ +dom(DomFrag, null); diff --git a/packages/babel-plugin-transform-react-jsx/test/fixtures/react/throw-if-pragma-set-but-not-pragmafrag-and-frag-used/actual.js b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/throw-if-pragma-set-but-not-pragmafrag-and-frag-used/actual.js new file mode 100644 index 0000000000..e78832be43 --- /dev/null +++ b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/throw-if-pragma-set-but-not-pragmafrag-and-frag-used/actual.js @@ -0,0 +1,3 @@ +/** @jsx dom */ + +<> diff --git a/packages/babel-plugin-transform-react-jsx/test/fixtures/react/throw-if-pragma-set-but-not-pragmafrag-and-frag-used/expected.js b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/throw-if-pragma-set-but-not-pragmafrag-and-frag-used/expected.js new file mode 100644 index 0000000000..42ac96f10c --- /dev/null +++ b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/throw-if-pragma-set-but-not-pragmafrag-and-frag-used/expected.js @@ -0,0 +1,2 @@ +/** @jsx dom */ +dom(React.Fragment, null); diff --git a/packages/babel-plugin-transform-react-jsx/test/fixtures/react/throw-if-pragma-set-but-not-pragmafrag-and-frag-used/options.json b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/throw-if-pragma-set-but-not-pragmafrag-and-frag-used/options.json new file mode 100644 index 0000000000..b89035fd10 --- /dev/null +++ b/packages/babel-plugin-transform-react-jsx/test/fixtures/react/throw-if-pragma-set-but-not-pragmafrag-and-frag-used/options.json @@ -0,0 +1,3 @@ +{ + "throws": "transform-react-jsx: pragma has been set but pragmafrag has not been set" +} diff --git a/packages/babel-types/README.md b/packages/babel-types/README.md index 63a5424d3e..cbfcd4e1ab 100644 --- a/packages/babel-types/README.md +++ b/packages/babel-types/README.md @@ -1059,7 +1059,7 @@ See also `t.isJSXAttribute(node, opts)` and `t.assertJSXAttribute(node, opts)`. Aliases: `JSX`, `Immutable` - `name`: `JSXIdentifier | JSXNamespacedName` (required) - - `value`: `JSXElement | StringLiteral | JSXExpressionContainer` (default: `null`) + - `value`: `JSXElement | JSXFragment | StringLiteral | JSXExpressionContainer` (default: `null`) --- @@ -1074,6 +1074,18 @@ Aliases: `JSX`, `Immutable` - `name`: `JSXIdentifier | JSXMemberExpression` (required) +--- + +### jSXClosingFragment +```javascript +t.jSXClosingFragment() +``` + +See also `t.isJSXClosingFragment(node, opts)` and `t.assertJSXClosingFragment(node, opts)`. + +Aliases: `JSX`, `Immutable` + + --- ### jSXElement @@ -1087,7 +1099,7 @@ Aliases: `JSX`, `Immutable`, `Expression` - `openingElement`: `JSXOpeningElement` (required) - `closingElement`: `JSXClosingElement` (default: `null`) - - `children`: `Array` (required) + - `children`: `Array` (required) - `selfClosing` (required) --- @@ -1117,6 +1129,21 @@ Aliases: `JSX`, `Immutable` --- +### jSXFragment +```javascript +t.jSXFragment(openingFragment, closingFragment, children) +``` + +See also `t.isJSXFragment(node, opts)` and `t.assertJSXFragment(node, opts)`. + +Aliases: `JSX`, `Immutable`, `Expression` + + - `openingFragment`: `JSXOpeningFragment` (required) + - `closingFragment`: `JSXClosingFragment` (required) + - `children`: `Array` (required) + +--- + ### jSXIdentifier ```javascript t.jSXIdentifier(name) @@ -1171,6 +1198,18 @@ Aliases: `JSX`, `Immutable` - `attributes`: `Array` (required) - `selfClosing`: `boolean` (default: `false`) +--- + +### jSXOpeningFragment +```javascript +t.jSXOpeningFragment() +``` + +See also `t.isJSXOpeningFragment(node, opts)` and `t.assertJSXOpeningFragment(node, opts)`. + +Aliases: `JSX`, `Immutable` + + --- ### jSXSpreadAttribute diff --git a/packages/babel-types/src/definitions/jsx.js b/packages/babel-types/src/definitions/jsx.js index 01ed6c7c5f..bfa2142e06 100644 --- a/packages/babel-types/src/definitions/jsx.js +++ b/packages/babel-types/src/definitions/jsx.js @@ -16,6 +16,7 @@ defineType("JSXAttribute", { optional: true, validate: assertNodeType( "JSXElement", + "JSXFragment", "StringLiteral", "JSXExpressionContainer", ), @@ -54,6 +55,7 @@ defineType("JSXElement", { "JSXExpressionContainer", "JSXSpreadChild", "JSXElement", + "JSXFragment", ), ), ), @@ -161,3 +163,39 @@ defineType("JSXText", { }, }, }); + +defineType("JSXFragment", { + builder: ["openingFragment", "closingFragment", "children"], + visitor: ["openingFragment", "children", "closingFragment"], + aliases: ["JSX", "Immutable", "Expression"], + fields: { + openingFragment: { + validate: assertNodeType("JSXOpeningFragment"), + }, + closingFragment: { + validate: assertNodeType("JSXClosingFragment"), + }, + children: { + validate: chain( + assertValueType("array"), + assertEach( + assertNodeType( + "JSXText", + "JSXExpressionContainer", + "JSXSpreadChild", + "JSXElement", + "JSXFragment", + ), + ), + ), + }, + }, +}); + +defineType("JSXOpeningFragment", { + aliases: ["JSX", "Immutable"], +}); + +defineType("JSXClosingFragment", { + aliases: ["JSX", "Immutable"], +});