diff --git a/package.json b/package.json index 6fb11ccfd..8c2bf084d 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": "18.2.0" }, "jest": { - "setupFilesAfterEnv": [ - "tests/setupTests.js" - ] + "setupFilesAfterEnv": ["tests/setupTests.js"] } } diff --git a/src/formatter/formatPropValue.js b/src/formatter/formatPropValue.js index 5170c3721..fa4834ab1 100644 --- a/src/formatter/formatPropValue.js +++ b/src/formatter/formatPropValue.js @@ -2,11 +2,12 @@ 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'; +import getWrappedComponentDisplayName from './getWrappedComponentDisplayName'; const escape = (s: string): string => s.replace(/"/g, '"'); @@ -53,6 +54,11 @@ const formatPropValue = ( )}}`; } + // handle memo & forwardRef + if (isPlainObject(propValue) && propValue.$$typeof) { + return `{${getWrappedComponentDisplayName(propValue)}}`; + } + 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..2656439ea 100644 --- a/src/formatter/formatPropValue.spec.js +++ b/src/formatter/formatPropValue.spec.js @@ -135,4 +135,42 @@ 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}'); + }); + + 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}'); + }); }); diff --git a/src/formatter/formatReactPortalNode.js b/src/formatter/formatReactPortalNode.js new file mode 100644 index 000000000..ad9f29a5b --- /dev/null +++ b/src/formatter/formatReactPortalNode.js @@ -0,0 +1,58 @@ +/* @flow */ + +import type { Key } from 'react'; +import formatReactElementNode from './formatReactElementNode'; +import type { Options } from './../options'; +import type { + ReactElementTreeNode, + ReactPortalTreeNode, + TreeNode, +} from './../tree'; +import spacer from './spacer'; + +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, containerSelector, childrens } = node; + + if (type !== 'ReactPortal') { + throw new Error( + `The "formatReactPortalNode" function could only format node of type "ReactPortal". Given: ${type}` + ); + } + + return ` + {ReactDOM.createPortal(${ + childrens.length + ? `\n${spacer(lvl + 1, options.tabStop)}${formatReactElementNode( + toReactElementTreeNode('', undefined, childrens), + inline, + lvl + 1, + options + )}\n` + : 'null' + }, document.querySelector(\`${containerSelector}\`))} + `.trim(); +}; diff --git a/src/formatter/formatReactPortalNode.spec.js b/src/formatter/formatReactPortalNode.spec.js new file mode 100644 index 000000000..17798dd63 --- /dev/null +++ b/src/formatter/formatReactPortalNode.spec.js @@ -0,0 +1,82 @@ +/* @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', + containerSelector: 'body', + childrens: [ + { + value: 'Hello world', + type: 'string', + }, + ], + }; + + expect(formatReactPortalNode(tree, false, 0, defaultOptions)) + .toMatchInlineSnapshot(` + "{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', + displayName: 'div', + props: { a: 'foo' }, + childrens: [], + }, + { + type: 'ReactElement', + displayName: 'div', + props: { b: 'bar' }, + childrens: [], + }, + ], + }; + + expect(formatReactPortalNode(tree, false, 0, defaultOptions)) + .toMatchInlineSnapshot(` + "{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(null, document.querySelector(\`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( diff --git a/src/formatter/getFunctionTypeName.js b/src/formatter/getFunctionTypeName.js new file mode 100644 index 000000000..863a234b2 --- /dev/null +++ b/src/formatter/getFunctionTypeName.js @@ -0,0 +1,10 @@ +/* @flow */ + +const getFunctionTypeName = (functionType: Function): 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 e7ec29b08..d5f1b5853 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..35ca0917c 100644 --- a/src/parser/parseReactElement.js +++ b/src/parser/parseReactElement.js @@ -2,48 +2,30 @@ import React, { type Element as ReactElement, Fragment } from 'react'; import { - ForwardRef, isContextConsumer, isContextProvider, isForwardRef, isLazy, isMemo, + isPortal, 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, + createReactPortalTreeNode, + 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 => { @@ -93,16 +75,40 @@ 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 += `.${Array.from(element.classList).join('.')}`; + } + + return selector; +}; + const parseReactElement = ( element: ReactElement<*> | string | number, options: Options ): 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 need react-dom flowtypes + constructSelector(element.containerInfo), + // $FlowFixMe need react-dom flowtypes + processChildren(element.children) + ); } else if (!React.isValidElement(element)) { throw new Error( `react-element-to-jsx-string: Expected a React.Element, got \`${typeof element}\`` @@ -123,9 +129,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 a30188849..5a6f7d5a6 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,44 @@ describe('parseReactElement', () => { ], }); }); + + it('should parse a react dom portal', () => { + expect( + 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', + displayName: 'div', + defaultProps: {}, + props: {}, + childrens: [], + }, + ], + }); + }); }); diff --git a/src/tree.js b/src/tree.js index efbf254af..8563769c5 100644 --- a/src/tree.js +++ b/src/tree.js @@ -30,11 +30,18 @@ export type ReactFragmentTreeNode = {| childrens: TreeNode[], |}; +export type ReactPortalTreeNode = {| + type: 'ReactPortal', + containerSelector: string, + childrens: TreeNode[], +|}; + export type TreeNode = | StringTreeNode | NumberTreeNode | ReactElementTreeNode - | ReactFragmentTreeNode; + | ReactFragmentTreeNode + | ReactPortalTreeNode; export const createStringTreeNode = (value: string): StringTreeNode => ({ type: 'string', @@ -67,3 +74,12 @@ export const createReactFragmentTreeNode = ( key, childrens, }); + +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 febf01823..e2bfde11d 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,13 @@ describe('createReactFragmentTreeNode', () => { }); }); }); + +describe('createReactPortalTreeNode', () => { + it('generate a react portal typed node payload', () => { + expect(createReactPortalTreeNode('#root', ['abc'])).toEqual({ + type: 'ReactPortal', + containerSelector: '#root', + childrens: ['abc'], + }); + }); +});