Skip to content
New issue

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

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

React: Support all React component types in JSX Decorator #26382

Merged
merged 4 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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