Skip to content

Commit

Permalink
Merge pull request #26382 from storybookjs/yann/fix-docs-jsx-in-react
Browse files Browse the repository at this point in the history
React: Support all React component types in JSX Decorator
  • Loading branch information
yannbf authored Mar 8, 2024
2 parents d369c9d + d98cfc9 commit a6b04dc
Show file tree
Hide file tree
Showing 2 changed files with 110 additions and 40 deletions.
105 changes: 68 additions & 37 deletions code/renderers/react/src/docs/jsxDecorator.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/* eslint-disable no-underscore-dangle */
import type { FC, PropsWithChildren } from 'react';
import React, { StrictMode, createElement, Profiler } from 'react';
import type { Mock } from 'vitest';
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);
Expand All @@ -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(`
Expand Down Expand Up @@ -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>
`);
});

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(StrictModeComponent, {}, 'I am StrictMode'), {}))
.toMatchInlineSnapshot(`
<StrictModeComponent>
I am StrictMode
</StrictModeComponent>
`);
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>
`);
});

Expand Down
45 changes: 42 additions & 3 deletions code/renderers/react/src/docs/jsxDecorator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: string = 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 {
Expand Down Expand Up @@ -44,7 +74,7 @@ type JSXOptions = Options & {
};

/** Apply the users parameters and render the jsx for a story */
export const renderJsx = (code: React.ReactElement, options: JSXOptions) => {
export const renderJsx = (code: React.ReactElement, options?: JSXOptions) => {
if (typeof code === 'undefined') {
logger.warn('Too many skip or undefined component');
return null;
Expand Down Expand Up @@ -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,
};
}

Expand Down

0 comments on commit a6b04dc

Please sign in to comment.