diff --git a/README.md b/README.md index 72d3d4882..cdc827660 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,18 @@ console.log(reactElementToJSXString(
Hello, world!
)); Either to sort or not props. If you use this lib to make some isomorphic rendering you should set it to false, otherwise this would lead to react invalid checksums as the prop order is part of react isomorphic checksum algorithm. +**options.useFragmentShortSyntax: boolean, default true** + + If true, fragment will be represented with the JSX short syntax `<>...` (when possible). + + If false, fragment will always be represented with the JSX explicit syntax `...`. + + According to [the specs](https://reactjs.org/docs/fragments.html): + - A keyed fragment will always use the explicit syntax: `...` + - An empty fragment will always use the explicit syntax: `` + + Note: to use fragment you must use React >= 16.2 + ## Environment requirements The environment you use to use `react-element-to-jsx-string` should have [ES2015](https://babeljs.io/learn-es2015/) support. diff --git a/src/formatter/formatProp.spec.js b/src/formatter/formatProp.spec.js index 7a6f5e3f7..700c8fa9b 100644 --- a/src/formatter/formatProp.spec.js +++ b/src/formatter/formatProp.spec.js @@ -115,7 +115,7 @@ describe('formatProp', () => { expect(formatPropValue).toHaveBeenCalledWith(false, true, 0, options); }); - it('should format a truthy boolean prop (with explicit synthax)', () => { + it('should format a truthy boolean prop (with explicit syntax)', () => { const options = { useBooleanShorthandSyntax: false, tabStop: 2, @@ -135,7 +135,7 @@ describe('formatProp', () => { expect(formatPropValue).toHaveBeenCalledWith(true, true, 0, options); }); - it('should format a falsy boolean prop (with explicit synthax)', () => { + it('should format a falsy boolean prop (with explicit syntax)', () => { const options = { useBooleanShorthandSyntax: false, tabStop: 2, diff --git a/src/formatter/formatReactElementNode.js b/src/formatter/formatReactElementNode.js index 49039be2b..516fdf4ea 100644 --- a/src/formatter/formatReactElementNode.js +++ b/src/formatter/formatReactElementNode.js @@ -196,7 +196,7 @@ export default ( out += childrens .reduce(mergeSiblingPlainStringChildrenReducer, []) .map(formatOneChildren(inline, newLvl, options)) - .join(`\n${spacer(newLvl, tabStop)}`); + .join(!inline ? `\n${spacer(newLvl, tabStop)}` : ''); if (!inline) { out += '\n'; diff --git a/src/formatter/formatReactFragmentNode.js b/src/formatter/formatReactFragmentNode.js new file mode 100644 index 000000000..ea0fdff30 --- /dev/null +++ b/src/formatter/formatReactFragmentNode.js @@ -0,0 +1,73 @@ +/* @flow */ + +import type { Key } from 'react'; +import formatReactElementNode from './formatReactElementNode'; +import type { Options } from './../options'; +import type { + ReactElementTreeNode, + ReactFragmentTreeNode, + TreeNode, +} from './../tree'; + +const REACT_FRAGMENT_TAG_NAME_SHORT_SYNTAX = ''; +const REACT_FRAGMENT_TAG_NAME_EXPLICIT_SYNTAX = 'React.Fragment'; + +const toReactElementTreeNode = ( + displayName: string, + key: ?Key, + childrens: TreeNode[] +): ReactElementTreeNode => { + let props = {}; + if (key) { + props = { key }; + } + + return { + type: 'ReactElement', + displayName, + props, + defaultProps: {}, + childrens, + }; +}; + +const isKeyedFragment = ({ key }: ReactFragmentTreeNode) => Boolean(key); +const hasNoChildren = ({ childrens }: ReactFragmentTreeNode) => + childrens.length === 0; + +export default ( + node: ReactFragmentTreeNode, + inline: boolean, + lvl: number, + options: Options +): string => { + const { type, key, childrens } = node; + + if (type !== 'ReactFragment') { + throw new Error( + `The "formatReactFragmentNode" function could only format node of type "ReactFragment". Given: ${ + type + }` + ); + } + + const { useFragmentShortSyntax } = options; + + let displayName; + if (useFragmentShortSyntax) { + if (hasNoChildren(node) || isKeyedFragment(node)) { + displayName = REACT_FRAGMENT_TAG_NAME_EXPLICIT_SYNTAX; + } else { + displayName = REACT_FRAGMENT_TAG_NAME_SHORT_SYNTAX; + } + } else { + displayName = REACT_FRAGMENT_TAG_NAME_EXPLICIT_SYNTAX; + } + + return formatReactElementNode( + toReactElementTreeNode(displayName, key, childrens), + inline, + lvl, + options + ); +}; diff --git a/src/formatter/formatReactFragmentNode.spec.js b/src/formatter/formatReactFragmentNode.spec.js new file mode 100644 index 000000000..14c9386a8 --- /dev/null +++ b/src/formatter/formatReactFragmentNode.spec.js @@ -0,0 +1,125 @@ +/* @flow */ + +import formatReactFragmentNode from './formatReactFragmentNode'; + +const defaultOptions = { + filterProps: [], + showDefaultProps: true, + showFunctions: false, + tabStop: 2, + useBooleanShorthandSyntax: true, + useFragmentShortSyntax: true, + sortProps: true, +}; + +describe('formatReactFragmentNode', () => { + it('should format a react fragment with a string as children', () => { + const tree = { + type: 'ReactFragment', + childrens: [ + { + value: 'Hello world', + type: 'string', + }, + ], + }; + + expect(formatReactFragmentNode(tree, false, 0, defaultOptions)).toEqual( + `<> + Hello world +` + ); + }); + + it('should format a react fragment with a key', () => { + const tree = { + type: 'ReactFragment', + key: 'foo', + childrens: [ + { + value: 'Hello world', + type: 'string', + }, + ], + }; + + expect(formatReactFragmentNode(tree, false, 0, defaultOptions)).toEqual( + ` + Hello world +` + ); + }); + + it('should format a react fragment with multiple childrens', () => { + const tree = { + type: 'ReactFragment', + childrens: [ + { + type: 'ReactElement', + displayName: 'div', + props: { a: 'foo' }, + childrens: [], + }, + { + type: 'ReactElement', + displayName: 'div', + props: { b: 'bar' }, + childrens: [], + }, + ], + }; + + expect(formatReactFragmentNode(tree, false, 0, defaultOptions)).toEqual( + `<> +
+
+` + ); + }); + + it('should format an empty react fragment', () => { + const tree = { + type: 'ReactFragment', + childrens: [], + }; + + expect(formatReactFragmentNode(tree, false, 0, defaultOptions)).toEqual( + '' + ); + }); + + it('should format an empty react fragment with key', () => { + const tree = { + type: 'ReactFragment', + key: 'foo', + childrens: [], + }; + + expect(formatReactFragmentNode(tree, false, 0, defaultOptions)).toEqual( + '' + ); + }); + + it('should format a react fragment using the explicit syntax', () => { + const tree = { + type: 'ReactFragment', + childrens: [ + { + value: 'Hello world', + type: 'string', + }, + ], + }; + + expect( + formatReactFragmentNode(tree, false, 0, { + ...defaultOptions, + ...{ useFragmentShortSyntax: false }, + }) + ).toEqual( + ` + Hello world +` + ); + }); +}); diff --git a/src/formatter/formatTreeNode.js b/src/formatter/formatTreeNode.js index 03ea5a091..0a24d7909 100644 --- a/src/formatter/formatTreeNode.js +++ b/src/formatter/formatTreeNode.js @@ -1,6 +1,7 @@ /* @flow */ import formatReactElementNode from './formatReactElementNode'; +import formatReactFragmentNode from './formatReactFragmentNode'; import type { Options } from './../options'; import type { TreeNode } from './../tree'; @@ -49,5 +50,9 @@ export default ( return formatReactElementNode(node, inline, lvl, options); } + if (node.type === 'ReactFragment') { + return formatReactFragmentNode(node, inline, lvl, options); + } + throw new TypeError(`Unknow format type "${node.type}"`); }; diff --git a/src/index.js b/src/index.js index 421cdb522..882b39c11 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,7 @@ const reactElementToJsxString = ( functionValue, tabStop = 2, useBooleanShorthandSyntax = true, + useFragmentShortSyntax = true, sortProps = true, maxInlineAttributesLineLength, displayName, @@ -30,6 +31,7 @@ const reactElementToJsxString = ( functionValue, tabStop, useBooleanShorthandSyntax, + useFragmentShortSyntax, sortProps, maxInlineAttributesLineLength, displayName, diff --git a/src/index.spec.js b/src/index.spec.js index c023cdb32..7a1538c15 100644 --- a/src/index.spec.js +++ b/src/index.spec.js @@ -2,7 +2,7 @@ /* eslint-disable react/no-string-refs */ -import React from 'react'; +import React, { Fragment } from 'react'; import { createRenderer } from 'react-test-renderer/shallow'; import reactElementToJSXString from './index'; import AnonymousStatelessComponent from './AnonymousStatelessComponent'; @@ -1032,4 +1032,59 @@ describe('reactElementToJSXString(ReactElement)', () => { '
' ); }); + + it('reactElementToJSXString(

foo

bar

)', () => { + expect( + reactElementToJSXString( + +

foo

+

bar

+
+ ) + ).toEqual( + `<> +

+ foo +

+

+ bar +

+` + ); + }); + + it('reactElementToJSXString(
)', () => { + expect( + reactElementToJSXString( + +
+
+ + ) + ).toEqual( + ` +
+
+` + ); + }); + + it('reactElementToJSXString()', () => { + expect(reactElementToJSXString()).toEqual(``); + }); + + it('reactElementToJSXString(
} />)', () => { + expect( + reactElementToJSXString( +
+
+
+ + } + /> + ) + ).toEqual(`
} />`); + }); }); diff --git a/src/options.js b/src/options.js index 160d925f5..ccb4a331f 100644 --- a/src/options.js +++ b/src/options.js @@ -9,6 +9,7 @@ export type Options = {| functionValue: Function, tabStop: number, useBooleanShorthandSyntax: boolean, + useFragmentShortSyntax: boolean, sortProps: boolean, maxInlineAttributesLineLength?: number, diff --git a/src/parser/parseReactElement.js b/src/parser/parseReactElement.js index 377c41fbe..7b991bd65 100644 --- a/src/parser/parseReactElement.js +++ b/src/parser/parseReactElement.js @@ -1,14 +1,17 @@ /* @flow */ -import React, { type Element as ReactElement } from 'react'; +import React, { type Element as ReactElement, Fragment } from 'react'; import type { Options } from './../options'; import { createStringTreeNode, createNumberTreeNode, createReactElementTreeNode, + createReactFragmentTreeNode, } from './../tree'; import type { TreeNode } from './../tree'; +const supportFragment = Boolean(Fragment); + const getReactElementDisplayName = (element: ReactElement<*>): string => element.type.displayName || element.type.name || // function name @@ -68,6 +71,10 @@ const parseReactElement = ( .filter(onlyMeaningfulChildren) .map(child => parseReactElement(child, options)); + if (supportFragment && element.type === Fragment) { + return createReactFragmentTreeNode(key, childrens); + } + return createReactElementTreeNode( displayName, props, diff --git a/src/parser/parseReactElement.spec.js b/src/parser/parseReactElement.spec.js index 3c5ff220f..a30188849 100644 --- a/src/parser/parseReactElement.spec.js +++ b/src/parser/parseReactElement.spec.js @@ -1,6 +1,6 @@ /* @flow */ -import React from 'react'; +import React, { Fragment } from 'react'; import parseReactElement from './parseReactElement'; const options = {}; @@ -151,4 +151,35 @@ describe('parseReactElement', () => { childrens: [], }); }); + + it('should parse a react fragment', () => { + expect( + parseReactElement( + +
+
+ , + options + ) + ).toEqual({ + type: 'ReactFragment', + key: 'foo', + childrens: [ + { + type: 'ReactElement', + displayName: 'div', + defaultProps: {}, + props: {}, + childrens: [], + }, + { + type: 'ReactElement', + displayName: 'div', + defaultProps: {}, + props: {}, + childrens: [], + }, + ], + }); + }); }); diff --git a/src/tree.js b/src/tree.js index f7f1d3b30..efbf254af 100644 --- a/src/tree.js +++ b/src/tree.js @@ -1,6 +1,8 @@ /* @flow */ /* eslint-disable no-use-before-define */ +import type { Key } from 'react'; + type PropsType = { [key: string]: any }; type DefaultPropsType = { [key: string]: any }; @@ -22,7 +24,17 @@ export type ReactElementTreeNode = {| childrens: TreeNode[], |}; -export type TreeNode = StringTreeNode | NumberTreeNode | ReactElementTreeNode; +export type ReactFragmentTreeNode = {| + type: 'ReactFragment', + key: ?Key, + childrens: TreeNode[], +|}; + +export type TreeNode = + | StringTreeNode + | NumberTreeNode + | ReactElementTreeNode + | ReactFragmentTreeNode; export const createStringTreeNode = (value: string): StringTreeNode => ({ type: 'string', @@ -46,3 +58,12 @@ export const createReactElementTreeNode = ( defaultProps, childrens, }); + +export const createReactFragmentTreeNode = ( + key: ?Key, + childrens: TreeNode[] +): ReactFragmentTreeNode => ({ + type: 'ReactFragment', + key, + childrens, +}); diff --git a/src/tree.spec.js b/src/tree.spec.js index 451eb447b..febf01823 100644 --- a/src/tree.spec.js +++ b/src/tree.spec.js @@ -4,6 +4,7 @@ import { createStringTreeNode, createNumberTreeNode, createReactElementTreeNode, + createReactFragmentTreeNode, } from './tree'; describe('createStringTreeNode', () => { @@ -39,3 +40,13 @@ describe('createReactElementTreeNode', () => { }); }); }); + +describe('createReactFragmentTreeNode', () => { + it('generate a react fragment typed node payload', () => { + expect(createReactFragmentTreeNode('foo', ['abc'])).toEqual({ + type: 'ReactFragment', + key: 'foo', + childrens: ['abc'], + }); + }); +});