React: Support all React component types in JSX Decorator

This commit is contained in:
Yann Braga 2024-03-08 11:48:55 +01:00
parent fc222b9b97
commit 13e872c838
2 changed files with 110 additions and 40 deletions

View File

@ -1,3 +1,4 @@
/* eslint-disable no-underscore-dangle */
import type { FC, PropsWithChildren } from 'react';
import React, { StrictMode, createElement, Profiler } from 'react';
import type { Mock } from 'vitest';
@ -5,7 +6,7 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import PropTypes from 'prop-types';
import { addons, useEffect } from '@storybook/preview-api';
import { SNIPPET_RENDERED } from '@storybook/docs-tools';
import { renderJsx, jsxDecorator } from './jsxDecorator';
import { renderJsx, jsxDecorator, getReactSymbolName } from './jsxDecorator';
vi.mock('@storybook/preview-api');
const mockedAddons = vi.mocked(addons);
@ -16,6 +17,18 @@ expect.addSnapshotSerializer({
test: (val) => typeof val === 'string',
});
describe('converts React Symbol to displayName string', () => {
const symbolCases = [
['react.suspense', 'React.Suspense'],
['react.strict_mode', 'React.StrictMode'],
['react.server_context.defaultValue', 'React.ServerContext.DefaultValue'],
];
it.each(symbolCases)('"%s" to "%s"', (symbol, expectedValue) => {
expect(getReactSymbolName(Symbol(symbol))).toEqual(expectedValue);
});
});
describe('renderJsx', () => {
it('basic', () => {
expect(renderJsx(<div>hello</div>, {})).toMatchInlineSnapshot(`
@ -139,53 +152,71 @@ describe('renderJsx', () => {
});
it('Profiler', () => {
function ProfilerComponent({ children }: any) {
return (
expect(
renderJsx(
<Profiler id="profiler-test" onRender={() => {}}>
<div>{children}</div>
</Profiler>
);
}
expect(renderJsx(createElement(ProfilerComponent, {}, 'I am Profiler'), {}))
.toMatchInlineSnapshot(`
<ProfilerComponent>
I am Profiler
</ProfilerComponent>
<div>I am in a Profiler</div>
</Profiler>,
{}
)
).toMatchInlineSnapshot(`
<React.Profiler
id="profiler-test"
onRender={() => {}}
>
<div>
I am in a Profiler
</div>
</React.Profiler>
`);
});
it('StrictMode', () => {
function StrictModeComponent({ children }: any) {
return (
<StrictMode>
<div>{children}</div>
</StrictMode>
);
}
expect(renderJsx(<StrictMode>I am StrictMode</StrictMode>, {})).toMatchInlineSnapshot(`
<React.StrictMode>
I am StrictMode
</React.StrictMode>
`);
});
expect(renderJsx(createElement(StrictModeComponent, {}, 'I am StrictMode'), {}))
.toMatchInlineSnapshot(`
<StrictModeComponent>
I am StrictMode
</StrictModeComponent>
`);
it('displayName coming from docgenInfo', () => {
function BasicComponent({ label }: any) {
return <button>{label}</button>;
}
BasicComponent.__docgenInfo = {
description: 'Some description',
methods: [],
displayName: 'Button',
props: {},
};
expect(
renderJsx(
createElement(
BasicComponent,
{
label: <p>Abcd</p>,
},
undefined
)
)
).toMatchInlineSnapshot(`<Button label={<p>Abcd</p>} />`);
});
it('Suspense', () => {
function SuspenseComponent({ children }: any) {
return (
expect(
renderJsx(
<React.Suspense fallback={null}>
<div>{children}</div>
</React.Suspense>
);
}
expect(renderJsx(createElement(SuspenseComponent, {}, 'I am Suspense'), {}))
.toMatchInlineSnapshot(`
<SuspenseComponent>
I am Suspense
</SuspenseComponent>
<div>I am in Suspense</div>
</React.Suspense>,
{}
)
).toMatchInlineSnapshot(`
<React.Suspense fallback={null}>
<div>
I am in Suspense
</div>
</React.Suspense>
`);
});

View File

@ -8,9 +8,39 @@ import { addons, useEffect } from '@storybook/preview-api';
import type { StoryContext, ArgsStoryFn, PartialStoryFn } from '@storybook/types';
import { SourceType, SNIPPET_RENDERED, getDocgenSection } from '@storybook/docs-tools';
import { logger } from '@storybook/client-logger';
import { isMemo, isForwardRef } from './lib';
import type { ReactRenderer } from '../types';
const toPascalCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
/**
* Converts a React symbol to a React-like displayName
*
* Symbols come from here
* https://github.com/facebook/react/blob/338dddc089d5865761219f02b5175db85c54c489/packages/react-devtools-shared/src/backend/ReactSymbols.js
*
* E.g.
* Symbol(react.suspense) -> React.Suspense
* Symbol(react.strict_mode) -> React.StrictMode
* Symbol(react.server_context.defaultValue) -> React.ServerContext.DefaultValue
*
* @param {Symbol} elementType - The symbol to convert
* @returns {string | null} A displayName for the Symbol in case elementType is a Symbol; otherwise, null.
*/
export const getReactSymbolName = (elementType: any): string => {
const symbolDescription = elementType.toString().replace(/^Symbol\((.*)\)$/, '$1');
const reactComponentName = symbolDescription
.split('.')
.map((segment) => {
// Split segment by underscore to handle cases like 'strict_mode' separately, and PascalCase them
return segment.split('_').map(toPascalCase).join('');
})
.join('.');
return reactComponentName;
};
// Recursively remove "_owner" property from elements to avoid crash on docs page when passing components as an array prop (#17482)
// Note: It may be better to use this function only in development environment.
function simplifyNodeForStringify(node: ReactNode): ReactNode {
@ -91,10 +121,19 @@ export const renderJsx = (code: React.ReactElement, options: JSXOptions) => {
*
* Cannot read properties of undefined (reading '__docgenInfo').
*/
} else if (renderedJSX?.type && getDocgenSection(renderedJSX.type, 'displayName')) {
} else {
displayNameDefaults = {
// To get exotic component names resolving properly
displayName: (el: any): string => getDocgenSection(el.type, 'displayName'),
displayName: (el: any): string =>
el.type.displayName || typeof el.type === 'symbol'
? getReactSymbolName(el.type)
: null ||
getDocgenSection(el.type, 'displayName') ||
(el.type.name !== '_default' ? el.type.name : null) ||
(typeof el.type === 'function' ? 'No Display Name' : null) ||
(isForwardRef(el.type) ? el.type.render.name : null) ||
(isMemo(el.type) ? el.type.type.name : null) ||
el.type,
};
}