From af5b9d277fbd8de3a9a09d451ede6cfe5ee9768c Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 29 Nov 2021 17:35:14 -0500 Subject: [PATCH 01/10] feat: replace memoized/forwarded raw components as prop values --- src/formatter/formatPropValue.js | 12 ++++++++++-- src/formatter/formatPropValue.spec.js | 28 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/formatter/formatPropValue.js b/src/formatter/formatPropValue.js index 5170c3721..ae9c6050c 100644 --- a/src/formatter/formatPropValue.js +++ b/src/formatter/formatPropValue.js @@ -2,11 +2,11 @@ import { isPlainObject } from 'is-plain-object'; import { isValidElement } from 'react'; +import type { Options } from './../options'; +import parseReactElement from './../parser/parseReactElement'; import formatComplexDataStructure from './formatComplexDataStructure'; import formatFunction from './formatFunction'; import formatTreeNode from './formatTreeNode'; -import type { Options } from './../options'; -import parseReactElement from './../parser/parseReactElement'; const escape = (s: string): string => s.replace(/"/g, '"'); @@ -53,6 +53,14 @@ const formatPropValue = ( )}}`; } + // handle forwardRef and memo + if (isPlainObject(propValue) && propValue.$$typeof) { + return `{${propValue.displayName || + propValue.type?.name || + propValue.render?.name || + 'Component'}}`; + } + if (propValue instanceof Date) { if (isNaN(propValue.valueOf())) { return `{new Date(NaN)}`; diff --git a/src/formatter/formatPropValue.spec.js b/src/formatter/formatPropValue.spec.js index 8d0a0eeb4..a24cc1baf 100644 --- a/src/formatter/formatPropValue.spec.js +++ b/src/formatter/formatPropValue.spec.js @@ -135,4 +135,32 @@ describe('formatPropValue', () => { expect(formatPropValue(new Map(), false, 0, {})).toBe('{[object Map]}'); }); + + it('should format a memoized React component prop value', () => { + const Component = React.memo(function Foo() { + return
; + }); + + expect(formatPropValue(Component, false, 0, {})).toBe('{Foo}'); + + const Unnamed = React.memo(function() { + return
; + }); + + expect(formatPropValue(Unnamed, false, 0, {})).toBe('{Component}'); + }); + + it('should format a forwarded React component prop value', () => { + const Component = React.forwardRef(function Foo(props, forwardedRef) { + return
; + }); + + expect(formatPropValue(Component, false, 0, {})).toBe('{Foo}'); + + const Unnamed = React.forwardRef(function(props, forwardedRef) { + return
; + }); + + expect(formatPropValue(Unnamed, false, 0, {})).toBe('{Component}'); + }); }); From 1e9789a780789f8a1fe7b993ff90b993c21fd038 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 29 Nov 2021 21:27:18 -0500 Subject: [PATCH 02/10] support multi-wrapped components --- package.json | 9 ++------- src/formatter/formatPropValue.js | 20 ++++++++++++++------ src/formatter/formatPropValue.spec.js | 10 ++++++++++ 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index fe54dd712..0824c9ed5 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,7 @@ "smoke": "node tests/smoke/run" }, "lint-staged": { - "*.js": [ - "prettier --write \"**/*.{js,json}\"", - "git add" - ] + "*.js": ["prettier --write \"**/*.{js,json}\"", "git add"] }, "author": { "name": "Algolia, Inc.", @@ -87,8 +84,6 @@ "react-is": "17.0.2" }, "jest": { - "setupFilesAfterEnv": [ - "tests/setupTests.js" - ] + "setupFilesAfterEnv": ["tests/setupTests.js"] } } diff --git a/src/formatter/formatPropValue.js b/src/formatter/formatPropValue.js index ae9c6050c..c6eb1110d 100644 --- a/src/formatter/formatPropValue.js +++ b/src/formatter/formatPropValue.js @@ -2,6 +2,7 @@ import { isPlainObject } from 'is-plain-object'; import { isValidElement } from 'react'; +import { ForwardRef, Memo } from 'react-is'; import type { Options } from './../options'; import parseReactElement from './../parser/parseReactElement'; import formatComplexDataStructure from './formatComplexDataStructure'; @@ -53,12 +54,19 @@ const formatPropValue = ( )}}`; } - // handle forwardRef and memo - if (isPlainObject(propValue) && propValue.$$typeof) { - return `{${propValue.displayName || - propValue.type?.name || - propValue.render?.name || - 'Component'}}`; + // handle memo & forwardRef + if ( + isPlainObject(propValue) && + (propValue.$$typeof === Memo || propValue.$$typeof === ForwardRef) + ) { + // render = forwardRef + // type = memo + const target = propValue.render || propValue.type; + + // go deeper if necessary + return target.$$typeof + ? formatPropValue(target, inline, lvl, options) + : `{${propValue.displayName || target.name || 'Component'}}`; } if (propValue instanceof Date) { diff --git a/src/formatter/formatPropValue.spec.js b/src/formatter/formatPropValue.spec.js index a24cc1baf..2656439ea 100644 --- a/src/formatter/formatPropValue.spec.js +++ b/src/formatter/formatPropValue.spec.js @@ -163,4 +163,14 @@ describe('formatPropValue', () => { expect(formatPropValue(Unnamed, false, 0, {})).toBe('{Component}'); }); + + it('should format a memoized & forwarded React component prop value', () => { + const Component = React.memo( + React.forwardRef(function Foo(props, forwardedRef) { + return
; + }) + ); + + expect(formatPropValue(Component, false, 0, {})).toBe('{Foo}'); + }); }); From fa6cae11e36948591f6420046c0f2ef4e915d40e Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 29 Nov 2021 21:48:30 -0500 Subject: [PATCH 03/10] refactor utility into shared functions --- src/formatter/formatPropValue.js | 16 +--- src/formatter/getFunctionTypeName.js | 10 +++ src/formatter/getFunctionTypeName.spec.js | 41 ++++++++++ .../getWrappedComponentDisplayName.js | 19 +++++ .../getWrappedComponentDisplayName.spec.js | 79 +++++++++++++++++++ src/index.spec.js | 10 ++- src/parser/parseReactElement.js | 30 ++----- 7 files changed, 166 insertions(+), 39 deletions(-) create mode 100644 src/formatter/getFunctionTypeName.js create mode 100644 src/formatter/getFunctionTypeName.spec.js create mode 100644 src/formatter/getWrappedComponentDisplayName.js create mode 100644 src/formatter/getWrappedComponentDisplayName.spec.js diff --git a/src/formatter/formatPropValue.js b/src/formatter/formatPropValue.js index c6eb1110d..fa4834ab1 100644 --- a/src/formatter/formatPropValue.js +++ b/src/formatter/formatPropValue.js @@ -2,12 +2,12 @@ import { isPlainObject } from 'is-plain-object'; import { isValidElement } from 'react'; -import { ForwardRef, Memo } from 'react-is'; import type { Options } from './../options'; import parseReactElement from './../parser/parseReactElement'; import formatComplexDataStructure from './formatComplexDataStructure'; import formatFunction from './formatFunction'; import formatTreeNode from './formatTreeNode'; +import getWrappedComponentDisplayName from './getWrappedComponentDisplayName'; const escape = (s: string): string => s.replace(/"/g, '"'); @@ -55,18 +55,8 @@ const formatPropValue = ( } // handle memo & forwardRef - if ( - isPlainObject(propValue) && - (propValue.$$typeof === Memo || propValue.$$typeof === ForwardRef) - ) { - // render = forwardRef - // type = memo - const target = propValue.render || propValue.type; - - // go deeper if necessary - return target.$$typeof - ? formatPropValue(target, inline, lvl, options) - : `{${propValue.displayName || target.name || 'Component'}}`; + if (isPlainObject(propValue) && propValue.$$typeof) { + return `{${getWrappedComponentDisplayName(propValue)}}`; } if (propValue instanceof Date) { diff --git a/src/formatter/getFunctionTypeName.js b/src/formatter/getFunctionTypeName.js new file mode 100644 index 000000000..6570b8ea2 --- /dev/null +++ b/src/formatter/getFunctionTypeName.js @@ -0,0 +1,10 @@ +/* @flow */ + +const getFunctionTypeName = (functionType): string => { + if (!functionType.name || functionType.name === '_default') { + return 'Component'; + } + return functionType.name; +}; + +export default getFunctionTypeName; diff --git a/src/formatter/getFunctionTypeName.spec.js b/src/formatter/getFunctionTypeName.spec.js new file mode 100644 index 000000000..20ac7c6e1 --- /dev/null +++ b/src/formatter/getFunctionTypeName.spec.js @@ -0,0 +1,41 @@ +/** + * @jest-environment jsdom + */ + +/* @flow */ + +import React from 'react'; +import getFunctionTypeName from './getFunctionTypeName'; + +function NamedStatelessComponent(props: { children: React.Children }) { + const { children } = props; + return
{children}
; +} + +const _default = function(props: { children: React.Children }) { + const { children } = props; + return
{children}
; +}; + +const NamelessComponent = function(props: { children: React.Children }) { + const { children } = props; + return
{children}
; +}; + +delete NamelessComponent.name; + +describe('getFunctionTypeName(Component)', () => { + it('getFunctionTypeName(NamedStatelessComponent)', () => { + expect(getFunctionTypeName(NamedStatelessComponent)).toEqual( + 'NamedStatelessComponent' + ); + }); + + it('getFunctionTypeName(_default)', () => { + expect(getFunctionTypeName(_default)).toEqual('Component'); + }); + + it('getFunctionTypeName(NamelessComponent)', () => { + expect(getFunctionTypeName(NamelessComponent)).toEqual('Component'); + }); +}); diff --git a/src/formatter/getWrappedComponentDisplayName.js b/src/formatter/getWrappedComponentDisplayName.js new file mode 100644 index 000000000..5611981e9 --- /dev/null +++ b/src/formatter/getWrappedComponentDisplayName.js @@ -0,0 +1,19 @@ +/* @flow */ + +import { ForwardRef, Memo } from 'react-is'; +import getFunctionTypeName from './getFunctionTypeName'; + +const getWrappedComponentDisplayName = (Component: *): string => { + switch (true) { + case Boolean(Component.displayName): + return Component.displayName; + case Component.$$typeof === Memo: + return getWrappedComponentDisplayName(Component.type); + case Component.$$typeof === ForwardRef: + return getWrappedComponentDisplayName(Component.render); + default: + return getFunctionTypeName(Component); + } +}; + +export default getWrappedComponentDisplayName; diff --git a/src/formatter/getWrappedComponentDisplayName.spec.js b/src/formatter/getWrappedComponentDisplayName.spec.js new file mode 100644 index 000000000..6a107907f --- /dev/null +++ b/src/formatter/getWrappedComponentDisplayName.spec.js @@ -0,0 +1,79 @@ +/** + * @jest-environment jsdom + */ + +/* @flow */ + +import React from 'react'; +import getWrappedComponentDisplayName from './getWrappedComponentDisplayName'; + +class TestComponent extends React.Component {} + +function NamedStatelessComponent(props: { children: React.Children }) { + const { children } = props; + return
{children}
; +} + +class DisplayNamePrecedence extends React.Component {} + +DisplayNamePrecedence.displayName = 'This should take precedence'; + +const MemoizedNamedStatelessComponent = React.memo(NamedStatelessComponent); + +const ForwardRefStatelessComponent = React.forwardRef((props, forwardedRef) => ( +
+)); + +const ForwardRefNamedStatelessComponent = React.forwardRef( + function BaseComponent(props, forwardedRef) { + return
; + } +); + +const MemoizedForwardRefNamedStatelessComponent = React.memo( + ForwardRefNamedStatelessComponent +); + +describe('getWrappedComponentDisplayName(Component)', () => { + it('getWrappedComponentDisplayName(TestComponent)', () => { + expect(getWrappedComponentDisplayName(TestComponent)).toEqual( + 'TestComponent' + ); + }); + + it('getWrappedComponentDisplayName(NamedStatelessComponent)', () => { + expect(getWrappedComponentDisplayName(NamedStatelessComponent)).toEqual( + 'NamedStatelessComponent' + ); + }); + + it('getWrappedComponentDisplayName(DisplayNamePrecedence)', () => { + expect(getWrappedComponentDisplayName(DisplayNamePrecedence)).toEqual( + 'This should take precedence' + ); + }); + + it('getWrappedComponentDisplayName(MemoizedNamedStatelessComponent)', () => { + expect( + getWrappedComponentDisplayName(MemoizedNamedStatelessComponent) + ).toEqual('NamedStatelessComponent'); + }); + + it('getWrappedComponentDisplayName(ForwardRefStatelessComponent)', () => { + expect( + getWrappedComponentDisplayName(ForwardRefStatelessComponent) + ).toEqual('Component'); + }); + + it('getWrappedComponentDisplayName(ForwardRefNamedStatelessComponent)', () => { + expect( + getWrappedComponentDisplayName(ForwardRefNamedStatelessComponent) + ).toEqual('BaseComponent'); + }); + + it('getWrappedComponentDisplayName(MemoizedForwardRefNamedStatelessComponent)', () => { + expect( + getWrappedComponentDisplayName(MemoizedForwardRefNamedStatelessComponent) + ).toEqual('BaseComponent'); + }); +}); diff --git a/src/index.spec.js b/src/index.spec.js index f94a1d6d8..501d71517 100644 --- a/src/index.spec.js +++ b/src/index.spec.js @@ -32,6 +32,8 @@ class DisplayNamePrecedence extends React.Component {} DisplayNamePrecedence.displayName = 'This should take precedence'; +const MemoizedNamedStatelessComponent = React.memo(NamedStatelessComponent); + describe('reactElementToJSXString(ReactElement)', () => { it('reactElementToJSXString()', () => { expect(reactElementToJSXString()).toEqual( @@ -45,9 +47,15 @@ describe('reactElementToJSXString(ReactElement)', () => { ); }); + it('reactElementToJSXString()', () => { + expect( + reactElementToJSXString() + ).toEqual(''); + }); + it('reactElementToJSXString()', () => { expect(reactElementToJSXString()).toEqual( - '' + '' ); }); diff --git a/src/parser/parseReactElement.js b/src/parser/parseReactElement.js index 589a59169..3d36b2748 100644 --- a/src/parser/parseReactElement.js +++ b/src/parser/parseReactElement.js @@ -1,8 +1,7 @@ /* @flow */ -import React, { type Element as ReactElement, Fragment } from 'react'; +import React, { Element as ReactElement, Fragment } from 'react'; import { - ForwardRef, isContextConsumer, isContextProvider, isForwardRef, @@ -11,39 +10,20 @@ import { isProfiler, isStrictMode, isSuspense, - Memo, } from 'react-is'; +import getFunctionTypeName from '../formatter/getFunctionTypeName'; +import getWrappedComponentDisplayName from '../formatter/getWrappedComponentDisplayName'; import type { Options } from './../options'; +import type { TreeNode } from './../tree'; import { - createStringTreeNode, createNumberTreeNode, createReactElementTreeNode, createReactFragmentTreeNode, + createStringTreeNode, } from './../tree'; -import type { TreeNode } from './../tree'; const supportFragment = Boolean(Fragment); -const getFunctionTypeName = (functionType): string => { - if (!functionType.name || functionType.name === '_default') { - return 'No Display Name'; - } - return functionType.name; -}; - -const getWrappedComponentDisplayName = (Component: *): string => { - switch (true) { - case Boolean(Component.displayName): - return Component.displayName; - case Component.$$typeof === Memo: - return getWrappedComponentDisplayName(Component.type); - case Component.$$typeof === ForwardRef: - return getWrappedComponentDisplayName(Component.render); - default: - return getFunctionTypeName(Component); - } -}; - // heavily inspired by: // https://github.com/facebook/react/blob/3746eaf985dd92f8aa5f5658941d07b6b855e9d9/packages/react-devtools-shared/src/backend/renderer.js#L399-L496 const getReactElementDisplayName = (element: ReactElement<*>): string => { From 57e5063a8cb72f003e19d8adaf4a72479951b475 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 29 Nov 2021 21:53:50 -0500 Subject: [PATCH 04/10] fix types --- src/formatter/getFunctionTypeName.js | 2 +- src/parser/parseReactElement.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/formatter/getFunctionTypeName.js b/src/formatter/getFunctionTypeName.js index 6570b8ea2..863a234b2 100644 --- a/src/formatter/getFunctionTypeName.js +++ b/src/formatter/getFunctionTypeName.js @@ -1,6 +1,6 @@ /* @flow */ -const getFunctionTypeName = (functionType): string => { +const getFunctionTypeName = (functionType: Function): string => { if (!functionType.name || functionType.name === '_default') { return 'Component'; } diff --git a/src/parser/parseReactElement.js b/src/parser/parseReactElement.js index 3d36b2748..0ecad3470 100644 --- a/src/parser/parseReactElement.js +++ b/src/parser/parseReactElement.js @@ -1,6 +1,6 @@ /* @flow */ -import React, { Element as ReactElement, Fragment } from 'react'; +import React, { type Element as ReactElement, Fragment } from 'react'; import { isContextConsumer, isContextProvider, From fcd9d83585fe735cc70f292e3ea0e6cf15f30068 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 6 Dec 2021 12:52:07 -0500 Subject: [PATCH 05/10] chore: add failing test for parsing react DOM portals --- src/parser/parseReactElement.spec.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/parser/parseReactElement.spec.js b/src/parser/parseReactElement.spec.js index a30188849..15ad29aac 100644 --- a/src/parser/parseReactElement.spec.js +++ b/src/parser/parseReactElement.spec.js @@ -1,6 +1,10 @@ +/** + * @jest-environment jsdom + */ /* @flow */ import React, { Fragment } from 'react'; +import ReactDOM from 'react-dom'; import parseReactElement from './parseReactElement'; const options = {}; @@ -182,4 +186,22 @@ describe('parseReactElement', () => { ], }); }); + + it('should parse a react dom portal', () => { + expect( + parseReactElement(ReactDOM.createPortal(
, document.body), options) + ).toEqual({ + type: 'ReactPortal', + key: 'foo', + childrens: [ + { + type: 'ReactElement', + displayName: 'div', + defaultProps: {}, + props: {}, + childrens: [], + }, + ], + }); + }); }); From 16dcbf7e4314d550f55e1fd42bcd9b0e1eb1c469 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 6 Dec 2021 13:04:04 -0500 Subject: [PATCH 06/10] feat: add support for serializing ReactDOM portal nodes --- src/parser/parseReactElement.js | 16 +++++++++++++--- src/parser/parseReactElement.spec.js | 1 - src/tree.js | 15 ++++++++++++++- src/tree.spec.js | 10 ++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/parser/parseReactElement.js b/src/parser/parseReactElement.js index 0ecad3470..4d56559bf 100644 --- a/src/parser/parseReactElement.js +++ b/src/parser/parseReactElement.js @@ -7,6 +7,7 @@ import { isForwardRef, isLazy, isMemo, + isPortal, isProfiler, isStrictMode, isSuspense, @@ -19,6 +20,7 @@ import { createNumberTreeNode, createReactElementTreeNode, createReactFragmentTreeNode, + createReactPortalTreeNode, createStringTreeNode, } from './../tree'; @@ -79,10 +81,20 @@ const parseReactElement = ( ): TreeNode => { const { displayName: displayNameFn = getReactElementDisplayName } = options; + const processChildren = children => + React.Children.toArray(children) + .filter(onlyMeaningfulChildren) + .map(child => parseReactElement(child, options)); + if (typeof element === 'string') { return createStringTreeNode(element); } else if (typeof element === 'number') { return createNumberTreeNode(element); + } else if (isPortal(element)) { + return createReactPortalTreeNode( + // $FlowFixMe + processChildren(element.children) + ); } else if (!React.isValidElement(element)) { throw new Error( `react-element-to-jsx-string: Expected a React.Element, got \`${typeof element}\`` @@ -103,9 +115,7 @@ const parseReactElement = ( } const defaultProps = filterProps(element.type.defaultProps || {}, noChildren); - const childrens = React.Children.toArray(element.props.children) - .filter(onlyMeaningfulChildren) - .map(child => parseReactElement(child, options)); + const childrens = processChildren(element.props.children); if (supportFragment && element.type === Fragment) { return createReactFragmentTreeNode(key, childrens); diff --git a/src/parser/parseReactElement.spec.js b/src/parser/parseReactElement.spec.js index 15ad29aac..bf051ed56 100644 --- a/src/parser/parseReactElement.spec.js +++ b/src/parser/parseReactElement.spec.js @@ -192,7 +192,6 @@ describe('parseReactElement', () => { parseReactElement(ReactDOM.createPortal(
, document.body), options) ).toEqual({ type: 'ReactPortal', - key: 'foo', childrens: [ { type: 'ReactElement', diff --git a/src/tree.js b/src/tree.js index efbf254af..02021879b 100644 --- a/src/tree.js +++ b/src/tree.js @@ -30,11 +30,17 @@ export type ReactFragmentTreeNode = {| childrens: TreeNode[], |}; +export type ReactPortalTreeNode = {| + type: 'ReactPortal', + childrens: TreeNode[], +|}; + export type TreeNode = | StringTreeNode | NumberTreeNode | ReactElementTreeNode - | ReactFragmentTreeNode; + | ReactFragmentTreeNode + | ReactPortalTreeNode; export const createStringTreeNode = (value: string): StringTreeNode => ({ type: 'string', @@ -67,3 +73,10 @@ export const createReactFragmentTreeNode = ( key, childrens, }); + +export const createReactPortalTreeNode = ( + childrens: TreeNode[] +): ReactPortalTreeNode => ({ + type: 'ReactPortal', + childrens, +}); diff --git a/src/tree.spec.js b/src/tree.spec.js index febf01823..e302d37f7 100644 --- a/src/tree.spec.js +++ b/src/tree.spec.js @@ -5,6 +5,7 @@ import { createNumberTreeNode, createReactElementTreeNode, createReactFragmentTreeNode, + createReactPortalTreeNode, } from './tree'; describe('createStringTreeNode', () => { @@ -50,3 +51,12 @@ describe('createReactFragmentTreeNode', () => { }); }); }); + +describe('createReactPortalTreeNode', () => { + it('generate a react portal typed node payload', () => { + expect(createReactPortalTreeNode(['abc'])).toEqual({ + type: 'ReactPortal', + childrens: ['abc'], + }); + }); +}); From 7ab5f83ff562abf3b36c0ff384301416a722beb6 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 6 Dec 2021 13:40:38 -0500 Subject: [PATCH 07/10] feat: add formatter for portal nodes --- src/formatter/formatReactPortalNode.js | 53 ++++++++++++ src/formatter/formatReactPortalNode.spec.js | 96 +++++++++++++++++++++ src/formatter/formatTreeNode.js | 5 ++ src/formatter/formatTreeNode.spec.js | 18 ++++ 4 files changed, 172 insertions(+) create mode 100644 src/formatter/formatReactPortalNode.js create mode 100644 src/formatter/formatReactPortalNode.spec.js diff --git a/src/formatter/formatReactPortalNode.js b/src/formatter/formatReactPortalNode.js new file mode 100644 index 000000000..7a80b5e21 --- /dev/null +++ b/src/formatter/formatReactPortalNode.js @@ -0,0 +1,53 @@ +/* @flow */ + +import type { Key } from 'react'; +import formatReactElementNode from './formatReactElementNode'; +import type { Options } from './../options'; +import type { + ReactElementTreeNode, + ReactPortalTreeNode, + TreeNode, +} from './../tree'; + +const toReactElementTreeNode = ( + displayName: string, + key: ?Key, + childrens: TreeNode[] +): ReactElementTreeNode => { + let props = {}; + if (key) { + props = { key }; + } + + return { + type: 'ReactElement', + displayName, + props, + defaultProps: {}, + childrens, + }; +}; + +export default ( + node: ReactPortalTreeNode, + inline: boolean, + lvl: number, + options: Options +): string => { + const { type, childrens } = node; + + if (type !== 'ReactPortal') { + throw new Error( + `The "formatReactPortalNode" function could only format node of type "ReactPortal". Given: ${type}` + ); + } + + return ` + {ReactDOM.createPortal(${formatReactElementNode( + toReactElementTreeNode('', undefined, childrens), + inline, + lvl, + options + )}, document.body)} + `.trim(); +}; diff --git a/src/formatter/formatReactPortalNode.spec.js b/src/formatter/formatReactPortalNode.spec.js new file mode 100644 index 000000000..4df08df99 --- /dev/null +++ b/src/formatter/formatReactPortalNode.spec.js @@ -0,0 +1,96 @@ +/* @flow */ + +import formatReactPortalNode from './formatReactPortalNode'; + +const defaultOptions = { + filterProps: [], + showDefaultProps: true, + showFunctions: false, + tabStop: 2, + useBooleanShorthandSyntax: true, + useFragmentShortSyntax: true, + sortProps: true, +}; + +describe('formatReactPortalNode', () => { + it('should format a react portal with a string as children', () => { + const tree = { + type: 'ReactPortal', + childrens: [ + { + value: 'Hello world', + type: 'string', + }, + ], + }; + + expect(formatReactPortalNode(tree, false, 0, defaultOptions)) + .toMatchInlineSnapshot(` + "{ReactDOM.createPortal(<> + Hello world + , document.body)}" + `); + }); + + it('should format a react portal with multiple childrens', () => { + const tree = { + type: 'ReactPortal', + childrens: [ + { + type: 'ReactElement', + displayName: 'div', + props: { a: 'foo' }, + childrens: [], + }, + { + type: 'ReactElement', + displayName: 'div', + props: { b: 'bar' }, + childrens: [], + }, + ], + }; + + expect(formatReactPortalNode(tree, false, 0, defaultOptions)) + .toMatchInlineSnapshot(` + "{ReactDOM.createPortal(<> +
+
+ , document.body)}" + `); + }); + + it('should format an empty react portal', () => { + const tree = { + type: 'ReactPortal', + childrens: [], + }; + + expect( + formatReactPortalNode(tree, false, 0, defaultOptions) + ).toMatchInlineSnapshot(`"{ReactDOM.createPortal(< />, document.body)}"`); + }); + + it('should format a react fragment using the explicit syntax', () => { + const tree = { + type: 'ReactPortal', + childrens: [ + { + value: 'Hello world', + type: 'string', + }, + ], + }; + + expect( + formatReactPortalNode(tree, false, 0, { + ...defaultOptions, + ...{ useFragmentShortSyntax: false }, + }) + ).toMatchInlineSnapshot(` + "{ReactDOM.createPortal(<> + Hello world + , document.body)}" + `); + }); +}); diff --git a/src/formatter/formatTreeNode.js b/src/formatter/formatTreeNode.js index 0d5ea3429..7b26f21a2 100644 --- a/src/formatter/formatTreeNode.js +++ b/src/formatter/formatTreeNode.js @@ -2,6 +2,7 @@ import formatReactElementNode from './formatReactElementNode'; import formatReactFragmentNode from './formatReactFragmentNode'; +import formatReactPortalNode from './formatReactPortalNode'; import type { Options } from './../options'; import type { TreeNode } from './../tree'; @@ -54,5 +55,9 @@ export default ( return formatReactFragmentNode(node, inline, lvl, options); } + if (node.type === 'ReactPortal') { + return formatReactPortalNode(node, inline, lvl, options); + } + throw new TypeError(`Unknow format type "${node.type}"`); }; diff --git a/src/formatter/formatTreeNode.spec.js b/src/formatter/formatTreeNode.spec.js index a3d172e51..efdaf4c08 100644 --- a/src/formatter/formatTreeNode.spec.js +++ b/src/formatter/formatTreeNode.spec.js @@ -6,6 +6,10 @@ jest.mock('./formatReactElementNode', () => () => '' ); +jest.mock('./formatReactPortalNode', () => () => + '' +); + describe('formatTreeNode', () => { it('should format number tree node', () => { expect(formatTreeNode({ type: 'number', value: 42 }, true, 0, {})).toBe( @@ -19,6 +23,20 @@ describe('formatTreeNode', () => { ); }); + it('should format react portal tree node', () => { + expect( + formatTreeNode( + { + type: 'ReactPortal', + childrens: ['abc'], + }, + true, + 0, + {} + ) + ).toBe(''); + }); + it('should format react element tree node', () => { expect( formatTreeNode( From f2d55eb50d432f7c8c5c502c8ebb3e9fa59f9d2a Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 6 Dec 2021 15:04:27 -0500 Subject: [PATCH 08/10] refactor: improve display of portalled contents --- src/formatter/formatReactPortalNode.js | 18 +++++--- src/formatter/formatReactPortalNode.spec.js | 48 ++++++++------------- src/parser/parseReactElement.js | 16 ++++++- src/parser/parseReactElement.spec.js | 23 ++++++++++ src/tree.js | 3 ++ src/tree.spec.js | 3 +- 6 files changed, 71 insertions(+), 40 deletions(-) diff --git a/src/formatter/formatReactPortalNode.js b/src/formatter/formatReactPortalNode.js index 7a80b5e21..6743136b8 100644 --- a/src/formatter/formatReactPortalNode.js +++ b/src/formatter/formatReactPortalNode.js @@ -34,7 +34,7 @@ export default ( lvl: number, options: Options ): string => { - const { type, childrens } = node; + const { type, containerSelector, childrens } = node; if (type !== 'ReactPortal') { throw new Error( @@ -43,11 +43,15 @@ export default ( } return ` - {ReactDOM.createPortal(${formatReactElementNode( - toReactElementTreeNode('', undefined, childrens), - inline, - lvl, - options - )}, document.body)} + {ReactDOM.createPortal(${ + childrens.length + ? `\n${formatReactElementNode( + toReactElementTreeNode('', undefined, childrens), + inline, + lvl + 2, + options + )}\n` + : 'null' + }, document.querySelector(\`${containerSelector}\`))} `.trim(); }; diff --git a/src/formatter/formatReactPortalNode.spec.js b/src/formatter/formatReactPortalNode.spec.js index 4df08df99..a754a9f01 100644 --- a/src/formatter/formatReactPortalNode.spec.js +++ b/src/formatter/formatReactPortalNode.spec.js @@ -16,6 +16,7 @@ describe('formatReactPortalNode', () => { it('should format a react portal with a string as children', () => { const tree = { type: 'ReactPortal', + containerSelector: 'body', childrens: [ { value: 'Hello world', @@ -26,15 +27,18 @@ describe('formatReactPortalNode', () => { expect(formatReactPortalNode(tree, false, 0, defaultOptions)) .toMatchInlineSnapshot(` - "{ReactDOM.createPortal(<> - Hello world - , document.body)}" + "{ReactDOM.createPortal( + <> + Hello world + + , document.querySelector(\`body\`))}" `); }); it('should format a react portal with multiple childrens', () => { const tree = { type: 'ReactPortal', + containerSelector: 'body', childrens: [ { type: 'ReactElement', @@ -53,44 +57,26 @@ describe('formatReactPortalNode', () => { expect(formatReactPortalNode(tree, false, 0, defaultOptions)) .toMatchInlineSnapshot(` - "{ReactDOM.createPortal(<> -
-
- , document.body)}" + "{ReactDOM.createPortal( + <> +
+
+ + , document.querySelector(\`body\`))}" `); }); it('should format an empty react portal', () => { const tree = { type: 'ReactPortal', + containerSelector: 'body', childrens: [], }; expect( formatReactPortalNode(tree, false, 0, defaultOptions) - ).toMatchInlineSnapshot(`"{ReactDOM.createPortal(< />, document.body)}"`); - }); - - it('should format a react fragment using the explicit syntax', () => { - const tree = { - type: 'ReactPortal', - childrens: [ - { - value: 'Hello world', - type: 'string', - }, - ], - }; - - expect( - formatReactPortalNode(tree, false, 0, { - ...defaultOptions, - ...{ useFragmentShortSyntax: false }, - }) - ).toMatchInlineSnapshot(` - "{ReactDOM.createPortal(<> - Hello world - , document.body)}" - `); + ).toMatchInlineSnapshot( + `"{ReactDOM.createPortal(null, document.querySelector(\`body\`))}"` + ); }); }); diff --git a/src/parser/parseReactElement.js b/src/parser/parseReactElement.js index 4d56559bf..5f233018f 100644 --- a/src/parser/parseReactElement.js +++ b/src/parser/parseReactElement.js @@ -75,6 +75,18 @@ const filterProps = (originalProps: {}, cb: (any, string) => boolean) => { return filteredProps; }; +const constructSelector = element => { + let selector = element.nodeName.toLowerCase(); + + if (element.id) { + selector = `#${element.id}`; + } else if (element.classList.length) { + selector += `.${element.classList.join('.')}`; + } + + return selector; +}; + const parseReactElement = ( element: ReactElement<*> | string | number, options: Options @@ -92,7 +104,9 @@ const parseReactElement = ( return createNumberTreeNode(element); } else if (isPortal(element)) { return createReactPortalTreeNode( - // $FlowFixMe + // $FlowFixMe need react-dom flowtypes + constructSelector(element.containerInfo), + // $FlowFixMe need react-dom flowtypes processChildren(element.children) ); } else if (!React.isValidElement(element)) { diff --git a/src/parser/parseReactElement.spec.js b/src/parser/parseReactElement.spec.js index bf051ed56..5a6f7d5a6 100644 --- a/src/parser/parseReactElement.spec.js +++ b/src/parser/parseReactElement.spec.js @@ -192,6 +192,29 @@ describe('parseReactElement', () => { parseReactElement(ReactDOM.createPortal(
, document.body), options) ).toEqual({ type: 'ReactPortal', + containerSelector: 'body', + childrens: [ + { + type: 'ReactElement', + displayName: 'div', + defaultProps: {}, + props: {}, + childrens: [], + }, + ], + }); + }); + + it('should create a more specific target selector for portals if possible', () => { + const targetRoot = document.createElement('div'); + targetRoot.id = 'foo'; + document.body.appendChild(targetRoot); + + expect( + parseReactElement(ReactDOM.createPortal(
, targetRoot), options) + ).toEqual({ + type: 'ReactPortal', + containerSelector: '#foo', childrens: [ { type: 'ReactElement', diff --git a/src/tree.js b/src/tree.js index 02021879b..8563769c5 100644 --- a/src/tree.js +++ b/src/tree.js @@ -32,6 +32,7 @@ export type ReactFragmentTreeNode = {| export type ReactPortalTreeNode = {| type: 'ReactPortal', + containerSelector: string, childrens: TreeNode[], |}; @@ -75,8 +76,10 @@ export const createReactFragmentTreeNode = ( }); export const createReactPortalTreeNode = ( + containerSelector: string, childrens: TreeNode[] ): ReactPortalTreeNode => ({ type: 'ReactPortal', + containerSelector, childrens, }); diff --git a/src/tree.spec.js b/src/tree.spec.js index e302d37f7..e2bfde11d 100644 --- a/src/tree.spec.js +++ b/src/tree.spec.js @@ -54,8 +54,9 @@ describe('createReactFragmentTreeNode', () => { describe('createReactPortalTreeNode', () => { it('generate a react portal typed node payload', () => { - expect(createReactPortalTreeNode(['abc'])).toEqual({ + expect(createReactPortalTreeNode('#root', ['abc'])).toEqual({ type: 'ReactPortal', + containerSelector: '#root', childrens: ['abc'], }); }); From cce033bd7863e09e55b4a9aeb43d5b41d95b387e Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 6 Dec 2021 15:08:38 -0500 Subject: [PATCH 09/10] fix: bitten by the arraylike goblin --- src/parser/parseReactElement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parser/parseReactElement.js b/src/parser/parseReactElement.js index 5f233018f..35ca0917c 100644 --- a/src/parser/parseReactElement.js +++ b/src/parser/parseReactElement.js @@ -81,7 +81,7 @@ const constructSelector = element => { if (element.id) { selector = `#${element.id}`; } else if (element.classList.length) { - selector += `.${element.classList.join('.')}`; + selector += `.${Array.from(element.classList).join('.')}`; } return selector; From 01c485fc72197bb4afdfb407acf4ea282e020b0c Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Mon, 6 Dec 2021 15:14:31 -0500 Subject: [PATCH 10/10] fix: indentation --- src/formatter/formatReactPortalNode.js | 5 +++-- src/formatter/formatReactPortalNode.spec.js | 14 +++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/formatter/formatReactPortalNode.js b/src/formatter/formatReactPortalNode.js index 6743136b8..ad9f29a5b 100644 --- a/src/formatter/formatReactPortalNode.js +++ b/src/formatter/formatReactPortalNode.js @@ -8,6 +8,7 @@ import type { ReactPortalTreeNode, TreeNode, } from './../tree'; +import spacer from './spacer'; const toReactElementTreeNode = ( displayName: string, @@ -45,10 +46,10 @@ export default ( return ` {ReactDOM.createPortal(${ childrens.length - ? `\n${formatReactElementNode( + ? `\n${spacer(lvl + 1, options.tabStop)}${formatReactElementNode( toReactElementTreeNode('', undefined, childrens), inline, - lvl + 2, + lvl + 1, options )}\n` : 'null' diff --git a/src/formatter/formatReactPortalNode.spec.js b/src/formatter/formatReactPortalNode.spec.js index a754a9f01..17798dd63 100644 --- a/src/formatter/formatReactPortalNode.spec.js +++ b/src/formatter/formatReactPortalNode.spec.js @@ -28,9 +28,9 @@ describe('formatReactPortalNode', () => { expect(formatReactPortalNode(tree, false, 0, defaultOptions)) .toMatchInlineSnapshot(` "{ReactDOM.createPortal( - <> - Hello world - + <> + Hello world + , document.querySelector(\`body\`))}" `); }); @@ -58,10 +58,10 @@ describe('formatReactPortalNode', () => { expect(formatReactPortalNode(tree, false, 0, defaultOptions)) .toMatchInlineSnapshot(` "{ReactDOM.createPortal( - <> -
-
- + <> +
+
+ , document.querySelector(\`body\`))}" `); });