Skip to content

Commit 228bce0

Browse files
authored
Merge pull request #33818 from storybookjs/kasper/react-docgen-typescript
React: Add react-docgen-typescript to component manifest
2 parents e9cfa91 + 6d555c8 commit 228bce0

33 files changed

+2303
-68
lines changed

code/core/src/core-server/utils/manifests/render-components-manifest.ts

Lines changed: 136 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import path from 'node:path';
22

33
import { groupBy } from 'storybook/internal/common';
44

5+
import type { ComponentDoc, PropItem } from 'react-docgen-typescript';
6+
57
import type { ComponentManifest, ComponentsManifest } from '../../../types';
68

79
/** Minimal docs entry type for rendering in the manifest debugger */
@@ -56,6 +58,9 @@ export function renderComponentsManifest(
5658
docsWithError: unattachedDocsWithError + attachedDocsWithError,
5759
};
5860

61+
const activeEngine = manifest?.meta?.docgen ?? 'react-docgen';
62+
const durationMs = manifest?.meta?.durationMs ?? 0;
63+
5964
// Top filters (clickable), no <b> tags; 1px active ring lives in CSS via :target
6065
const allPill = `<a class="filter-pill all" data-k="all" href="#filter-all">All</a>`;
6166
const compErrorsPill =
@@ -695,6 +700,28 @@ export function renderComponentsManifest(
695700
</header>
696701
<main>
697702
<div class="wrap">
703+
${
704+
activeEngine === 'react-docgen'
705+
? `<div class="note info" style="margin-bottom: 16px;">
706+
<strong>Tip:</strong> You are using <code>react-docgen</code> (the default). Generation took <strong>${(durationMs / 1000).toFixed(1)}s</strong>. For higher quality prop types, consider switching to <code>react-docgen-typescript</code> in your <code>main.ts</code>:
707+
<pre><code>typescript: {
708+
reactDocgen: 'react-docgen-typescript',
709+
}</code></pre>
710+
Note: <code>react-docgen-typescript</code> can be slower. If performance is acceptable for your project, it generally produces better results.
711+
<a href="https://storybook.js.org/docs/api/main-config/main-config-typescript#reactdocgen" target="_blank">Learn more</a>
712+
</div>`
713+
: activeEngine === 'react-docgen-typescript' && durationMs > 7500
714+
? `<div class="note err" style="margin-bottom: 16px;">
715+
<strong>Performance warning:</strong> <code>react-docgen-typescript</code> took <strong>${(durationMs / 1000).toFixed(1)}s</strong> to generate the manifest. This delay applies every time the manifest is used by an agent. Consider switching to the faster <code>react-docgen</code> in your <code>main.ts</code>:
716+
<pre><code>typescript: {
717+
reactDocgen: 'react-docgen',
718+
}</code></pre>
719+
<a href="https://storybook.js.org/docs/api/main-config/main-config-typescript#reactdocgen" target="_blank">Learn more</a>
720+
</div>`
721+
: `<div class="note ok" style="margin-bottom: 16px;">
722+
Using <code>${activeEngine}</code>. Generation took <strong>${(durationMs / 1000).toFixed(1)}s</strong>.
723+
</div>`
724+
}
698725
${
699726
grid
700727
? `<h2 class="section-title">Components</h2>
@@ -887,10 +914,27 @@ function renderComponentCard(key: string, c: ComponentManifestWithDocs, id: stri
887914
? `<label for="${slug}-docs" class="badge ${a.docsErrors > 0 ? 'err' : 'ok'} as-toggle">${a.docsErrors > 0 ? `${a.docsErrors}/${a.totalDocs} doc errors` : `${a.totalDocs} ${plural(a.totalDocs, 'doc')}`}</label>`
888915
: '';
889916

890-
// When there is no prop type error, try to read prop types from reactDocgen if present
891-
const reactDocgen: any = !a.hasPropTypeError && 'reactDocgen' in c && c.reactDocgen;
917+
// Determine which docgen engine produced results (they are now mutually exclusive)
918+
const reactDocgen =
919+
!a.hasPropTypeError && 'reactDocgen' in c ? (c.reactDocgen as DocgenDoc) : undefined;
920+
const reactDocgenTypescriptData =
921+
!a.hasPropTypeError && 'reactDocgenTypescript' in c
922+
? (c.reactDocgenTypescript as RdtComponentDoc)
923+
: undefined;
924+
892925
const parsedDocgen = reactDocgen ? parseReactDocgen(reactDocgen) : undefined;
893-
const propEntries = parsedDocgen ? Object.entries(parsedDocgen.props ?? {}) : [];
926+
const parsedReactDocgenTypescript = reactDocgenTypescriptData
927+
? parseReactDocgenTypescript(reactDocgenTypescriptData)
928+
: undefined;
929+
930+
// Use whichever engine is active
931+
const activeParsed = parsedDocgen ?? parsedReactDocgenTypescript;
932+
const cardEngine = parsedDocgen
933+
? 'react-docgen'
934+
: parsedReactDocgenTypescript
935+
? 'react-docgen-typescript'
936+
: '';
937+
const propEntries = activeParsed ? Object.entries(activeParsed.props ?? {}) : [];
894938
const propTypesBadge =
895939
!a.hasPropTypeError && propEntries.length > 0
896940
? `<label for="${slug}-props" class="badge ok as-toggle">${propEntries.length} ${plural(propEntries.length, 'prop type')}</label>`
@@ -927,7 +971,6 @@ function renderComponentCard(key: string, c: ComponentManifestWithDocs, id: stri
927971
.join('')
928972
: '';
929973

930-
esc(c.error?.message || 'Unknown error');
931974
return `
932975
<article
933976
class="card
@@ -983,10 +1026,22 @@ function renderComponentCard(key: string, c: ComponentManifestWithDocs, id: stri
9831026
<div class="panel panel-props">
9841027
<div class="note ok">
9851028
<div class="row">
986-
<span class="ex-name">Prop types</span>
1029+
<span class="ex-name">Prop types <small>(${cardEngine})</small></span>
9871030
<span class="badge ok">${propEntries.length} ${plural(propEntries.length, 'prop type')}</span>
9881031
</div>
989-
<pre><code>Component: ${reactDocgen?.definedInFile ? esc(path.relative(process.cwd(), reactDocgen.definedInFile)) : ''}${reactDocgen?.exportName ? '::' + esc(reactDocgen?.exportName) : ''}</code></pre>
1032+
<pre><code>Component: ${
1033+
reactDocgen?.definedInFile
1034+
? esc(path.relative(process.cwd(), reactDocgen.definedInFile))
1035+
: reactDocgenTypescriptData?.filePath
1036+
? esc(path.relative(process.cwd(), reactDocgenTypescriptData.filePath))
1037+
: ''
1038+
}${
1039+
reactDocgen?.exportName
1040+
? '::' + esc(reactDocgen.exportName)
1041+
: reactDocgenTypescriptData?.exportName
1042+
? '::' + esc(reactDocgenTypescriptData.exportName)
1043+
: ''
1044+
}</code></pre>
9901045
<pre><code>Props:</code></pre>
9911046
<pre><code>${esc(propsCode)}</code></pre>
9921047
</div>
@@ -1081,20 +1136,70 @@ function renderComponentCard(key: string, c: ComponentManifestWithDocs, id: stri
10811136
</article>`;
10821137
}
10831138

1139+
type ParsedProp = {
1140+
description?: string;
1141+
type?: string;
1142+
defaultValue?: string;
1143+
required?: boolean;
1144+
};
1145+
10841146
type ParsedDocgen = {
1085-
props: Record<
1086-
string,
1087-
{
1088-
description?: string;
1089-
type?: string;
1090-
defaultValue?: string;
1091-
required?: boolean;
1092-
}
1093-
>;
1147+
props: Record<string, ParsedProp>;
1148+
};
1149+
1150+
type RdtComponentDoc = ComponentDoc & { exportName?: string };
1151+
1152+
const parseReactDocgenTypescript = (reactDocgenTypescript: RdtComponentDoc): ParsedDocgen => {
1153+
const props: Record<string, PropItem> = reactDocgenTypescript.props ?? {};
1154+
return {
1155+
props: Object.fromEntries(
1156+
Object.entries(props).map(([propName, prop]) => [
1157+
propName,
1158+
{
1159+
description: prop.description,
1160+
// RDT uses prop.type.name as a flat string (e.g. "() => void", "{ id: string }")
1161+
// For enums, prefer prop.type.raw which has the full union
1162+
type: prop.type?.raw ?? prop.type?.name,
1163+
defaultValue: prop.defaultValue?.value,
1164+
required: prop.required,
1165+
},
1166+
])
1167+
),
1168+
};
10941169
};
10951170

1096-
const parseReactDocgen = (reactDocgen: any): ParsedDocgen => {
1097-
const props: Record<string, any> = (reactDocgen as any)?.props ?? {};
1171+
/** Shape of a react-docgen tsType node (recursive) */
1172+
interface DocgenTsType {
1173+
name?: string;
1174+
raw?: string;
1175+
value?: string;
1176+
elements?: DocgenTsType[];
1177+
type?: string;
1178+
signature?: {
1179+
arguments?: { name: string; type?: DocgenTsType }[];
1180+
return?: DocgenTsType;
1181+
properties?: { key: string; value?: DocgenTsType & { required?: boolean } }[];
1182+
};
1183+
}
1184+
1185+
/** Shape of a single prop from react-docgen's Documentation.props */
1186+
interface DocgenPropItem {
1187+
description?: string;
1188+
tsType?: DocgenTsType;
1189+
type?: DocgenTsType;
1190+
defaultValue?: { value?: string } | null;
1191+
required?: boolean;
1192+
}
1193+
1194+
/** Shape of react-docgen's Documentation (only fields we read) */
1195+
interface DocgenDoc {
1196+
props?: Record<string, DocgenPropItem>;
1197+
definedInFile?: string;
1198+
exportName?: string;
1199+
}
1200+
1201+
const parseReactDocgen = (reactDocgen: DocgenDoc): ParsedDocgen => {
1202+
const props = reactDocgen.props ?? {};
10981203
return {
10991204
props: Object.fromEntries(
11001205
Object.entries(props).map(([propName, prop]) => [
@@ -1111,54 +1216,53 @@ const parseReactDocgen = (reactDocgen: any): ParsedDocgen => {
11111216
};
11121217

11131218
// Serialize a react-docgen tsType into a TypeScript-like string when raw is not available
1114-
function serializeTsType(tsType: any): string | undefined {
1219+
function serializeTsType(tsType: DocgenTsType | undefined): string | undefined {
11151220
if (!tsType) {
11161221
return undefined;
11171222
}
11181223
// Prefer raw if provided
1119-
// Prefer raw if provided
1120-
if ('raw' in tsType && typeof tsType.raw === 'string' && tsType.raw.trim().length > 0) {
1224+
if (tsType.raw && tsType.raw.trim().length > 0) {
11211225
return tsType.raw;
11221226
}
11231227

11241228
if (!tsType.name) {
11251229
return undefined;
11261230
}
11271231

1128-
if ('elements' in tsType) {
1232+
if (tsType.elements) {
11291233
if (tsType.name === 'union') {
1130-
const parts = (tsType.elements ?? []).map((el: any) => serializeTsType(el) ?? 'unknown');
1234+
const parts = tsType.elements.map((el) => serializeTsType(el) ?? 'unknown');
11311235
return parts.join(' | ');
11321236
}
11331237
if (tsType.name === 'intersection') {
1134-
const parts = (tsType.elements ?? []).map((el: any) => serializeTsType(el) ?? 'unknown');
1238+
const parts = tsType.elements.map((el) => serializeTsType(el) ?? 'unknown');
11351239
return parts.join(' & ');
11361240
}
11371241
if (tsType.name === 'Array') {
11381242
// Prefer raw earlier; here build fallback
1139-
const el = (tsType.elements ?? [])[0];
1243+
const el = tsType.elements[0];
11401244
const inner = serializeTsType(el) ?? 'unknown';
11411245
return `${inner}[]`;
11421246
}
11431247
if (tsType.name === 'tuple') {
1144-
const parts = (tsType.elements ?? []).map((el: any) => serializeTsType(el) ?? 'unknown');
1248+
const parts = tsType.elements.map((el) => serializeTsType(el) ?? 'unknown');
11451249
return `[${parts.join(', ')}]`;
11461250
}
11471251
}
1148-
if ('value' in tsType && tsType.name === 'literal') {
1252+
if (tsType.value && tsType.name === 'literal') {
11491253
return tsType.value;
11501254
}
1151-
if ('signature' in tsType && tsType.name === 'signature') {
1255+
if (tsType.signature && tsType.name === 'signature') {
11521256
if (tsType.type === 'function') {
1153-
const args = (tsType.signature?.arguments ?? []).map((a: any) => {
1257+
const args = (tsType.signature.arguments ?? []).map((a) => {
11541258
const argType = serializeTsType(a.type) ?? 'any';
11551259
return `${a.name}: ${argType}`;
11561260
});
1157-
const ret = serializeTsType(tsType.signature?.return) ?? 'void';
1261+
const ret = serializeTsType(tsType.signature.return) ?? 'void';
11581262
return `(${args.join(', ')}) => ${ret}`;
11591263
}
11601264
if (tsType.type === 'object') {
1161-
const props = (tsType.signature?.properties ?? []).map((p: any) => {
1265+
const props = (tsType.signature.properties ?? []).map((p) => {
11621266
const req: boolean = Boolean(p.value?.required);
11631267
const propType = serializeTsType(p.value) ?? 'any';
11641268
return `${p.key}${req ? '' : '?'}: ${propType}`;
@@ -1168,8 +1272,8 @@ function serializeTsType(tsType: any): string | undefined {
11681272
return 'unknown';
11691273
}
11701274
// Default case (Generic like Item<TMeta>)
1171-
if ('elements' in tsType) {
1172-
const inner = (tsType.elements ?? []).map((el: any) => serializeTsType(el) ?? 'unknown');
1275+
if (tsType.elements) {
1276+
const inner = tsType.elements.map((el) => serializeTsType(el) ?? 'unknown');
11731277

11741278
if (inner.length > 0) {
11751279
return `${tsType.name}<${inner.join(', ')}>`;

code/core/src/types/modules/core-common.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,10 @@ export interface ComponentManifest {
369369
export interface ComponentsManifest {
370370
v: number;
371371
components: Record<string, ComponentManifest>;
372+
meta?: {
373+
docgen: 'react-docgen' | 'react-docgen-typescript';
374+
durationMs: number;
375+
};
372376
}
373377

374378
type ManifestName = string;

code/renderers/react/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
"dependencies": {
5555
"@storybook/global": "^5.0.0",
5656
"@storybook/react-dom-shim": "workspace:*",
57-
"react-docgen": "^8.0.2"
57+
"react-docgen": "^8.0.2",
58+
"react-docgen-typescript": "^2.2.2"
5859
},
5960
"devDependencies": {
6061
"@types/babel-plugin-react-docgen": "^4.2.3",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface CardProps {
2+
title: string;
3+
}
4+
export const Card = (props: CardProps) => null;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
interface ButtonProps {
2+
label: string;
3+
disabled?: boolean;
4+
}
5+
export function Button(props: ButtonProps) {
6+
return null;
7+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
interface IconProps {
2+
name: string;
3+
size?: number;
4+
}
5+
function Icon(props: IconProps) {
6+
return null;
7+
}
8+
export default Icon;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
interface AlertProps {
2+
message: string;
3+
severity?: string;
4+
}
5+
export function Alert({ message, severity = 'info' }: AlertProps) {
6+
return null;
7+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { FC } from 'react';
2+
3+
interface ModalProps {
4+
title: string;
5+
open?: boolean;
6+
}
7+
8+
// Component has an explicit displayName that differs from the variable name
9+
const InternalModal: FC<ModalProps> = (props) => null as any;
10+
InternalModal.displayName = 'FancyModal';
11+
12+
export { InternalModal as Modal };
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
interface TooltipProps {
2+
/** The content to display */
3+
content: string;
4+
}
5+
/** A tooltip component. */
6+
export function Tooltip(props: TooltipProps) {
7+
return null;
8+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { ButtonHTMLAttributes } from 'react';
2+
3+
interface HtmlButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
4+
/** The button variant */
5+
variant?: 'solid' | 'outline';
6+
}
7+
8+
export function HtmlButton(props: HtmlButtonProps) {
9+
return null;
10+
}

0 commit comments

Comments
 (0)