Skip to content

feat: replace memoized/forwarded raw components as prop values #682

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
9 changes: 2 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -87,8 +84,6 @@
"react-is": "18.2.0"
},
"jest": {
"setupFilesAfterEnv": [
"<rootDir>tests/setupTests.js"
]
"setupFilesAfterEnv": ["<rootDir>tests/setupTests.js"]
}
}
10 changes: 8 additions & 2 deletions src/formatter/formatPropValue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '&quot;');

Expand Down Expand Up @@ -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)}`;
Expand Down
38 changes: 38 additions & 0 deletions src/formatter/formatPropValue.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div />;
});

expect(formatPropValue(Component, false, 0, {})).toBe('{Foo}');

const Unnamed = React.memo(function() {
return <div />;
});

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 <div {...props} ref={forwardedRef} />;
});

expect(formatPropValue(Component, false, 0, {})).toBe('{Foo}');

const Unnamed = React.forwardRef(function(props, forwardedRef) {
return <div {...props} ref={forwardedRef} />;
});

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 <div {...props} ref={forwardedRef} />;
})
);

expect(formatPropValue(Component, false, 0, {})).toBe('{Foo}');
});
});
58 changes: 58 additions & 0 deletions src/formatter/formatReactPortalNode.js
Original file line number Diff line number Diff line change
@@ -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();
};
82 changes: 82 additions & 0 deletions src/formatter/formatReactPortalNode.spec.js
Original file line number Diff line number Diff line change
@@ -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(
<>
<div a=\\"foo\\" />
<div b=\\"bar\\" />
</>
, 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\`))}"`
);
});
});
5 changes: 5 additions & 0 deletions src/formatter/formatTreeNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}"`);
};
18 changes: 18 additions & 0 deletions src/formatter/formatTreeNode.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ jest.mock('./formatReactElementNode', () => () =>
'<MockedFormatReactElementNodeResult />'
);

jest.mock('./formatReactPortalNode', () => () =>
'<MockedFormatReactPortalNodeResult />'
);

describe('formatTreeNode', () => {
it('should format number tree node', () => {
expect(formatTreeNode({ type: 'number', value: 42 }, true, 0, {})).toBe(
Expand All @@ -19,6 +23,20 @@ describe('formatTreeNode', () => {
);
});

it('should format react portal tree node', () => {
expect(
formatTreeNode(
{
type: 'ReactPortal',
childrens: ['abc'],
},
true,
0,
{}
)
).toBe('<MockedFormatReactPortalNodeResult />');
});

it('should format react element tree node', () => {
expect(
formatTreeNode(
Expand Down
10 changes: 10 additions & 0 deletions src/formatter/getFunctionTypeName.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* @flow */

const getFunctionTypeName = (functionType: Function): string => {
if (!functionType.name || functionType.name === '_default') {
return 'Component';
}
return functionType.name;
};

export default getFunctionTypeName;
41 changes: 41 additions & 0 deletions src/formatter/getFunctionTypeName.spec.js
Original file line number Diff line number Diff line change
@@ -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 <div>{children}</div>;
}

const _default = function(props: { children: React.Children }) {
const { children } = props;
return <div>{children}</div>;
};

const NamelessComponent = function(props: { children: React.Children }) {
const { children } = props;
return <div>{children}</div>;
};

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');
});
});
19 changes: 19 additions & 0 deletions src/formatter/getWrappedComponentDisplayName.js
Original file line number Diff line number Diff line change
@@ -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;
Loading